Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,8 @@ const form = useForm({

### Icon Usage

Memoh 图标层的目标契约见 [`packages/icons/README.md`](../../packages/icons/README.md)。下述 Lucide / 品牌图标划分描述现有接入方式;新增光学校准或定制图标遵循该契约,在图标层实现,不在页面或菜单调用处补偿。现有直接导入在迁移期间保留。

- **Lucide** (primary): Direct component imports from `lucide-vue-next`. Example: `import { Plus, Search, Bot } from 'lucide-vue-next'` → `<Plus class="size-4" />`. Used for all UI icons (actions, navigation, status indicators, etc.).
- **`@memohai/icon`** (brand icons): Workspace package (`packages/icons/`) providing AI provider, search engine, and channel platform SVG icons as Vue components. Example: `import { Openai, Claude } from '@memohai/icon'`.
- **Do NOT use FontAwesome** for new code. Legacy FontAwesome usage remains only in commented-out code blocks. Always use Lucide for UI icons and `@memohai/icon` for brand logos.
Expand Down
36 changes: 29 additions & 7 deletions apps/web/src/components/computer/bot-computer-access-dialog.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<template>
<Dialog v-model:open="open">
<DialogContent>
<DialogPanel
width="xl"
footer
>
<DialogHeader class="pr-8">
<DialogTitle class="break-words">
{{ subjectName }}
Expand All @@ -10,27 +13,40 @@
</DialogDescription>
</DialogHeader>

<ComputerAccessList
:runtime="runtime"
:bot="bot"
/>
<DialogBody>
<ComputerAccessList
:runtime="runtime"
:bot="bot"
/>
</DialogBody>

<DialogFooter>
<Button
v-if="subject === 'bot'"
variant="outline"
@click="addComputer"
>
<Plus />
{{ t('chat.continueOn.addComputer') }}
</Button>
<Button @click="open = false">
{{ t('computerAccess.done') }}
</Button>
</DialogFooter>
</DialogContent>
</DialogPanel>
</Dialog>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Plus } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import {
Button,
Dialog,
DialogContent,
DialogPanel,
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
Expand All @@ -49,6 +65,12 @@ const props = defineProps<{
const open = defineModel<boolean>('open', { default: false })

const { t } = useI18n()
const router = useRouter()

function addComputer(): void {
open.value = false
void router.push({ name: 'runtimes', query: { connect: '1' } })
}

const subject = computed<'runtime' | 'bot'>(() => (props.runtime ? 'runtime' : 'bot'))
const subjectName = computed(() => (
Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/components/computer/computer-access-list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,19 @@
</div>

<template v-else>
<SettingsSection v-if="rows.length || subject === 'bot'">
<SettingsSection
v-if="rows.length || subject === 'bot'"
bordered
>
<!-- Bot direction always lists the native workspace first: it is part
of every bot and can never be revoked, so it gets a caption instead
of a switch. -->
of every bot and can never be revoked, so its switch stays enabled and cannot be edited. -->
<SettingsRow
v-if="subject === 'bot'"
:label="t('bots.remoteRuntime.nativeWorkspace')"
:description="t('computerAccess.nativeAlwaysOn')"
>
<template #leading>
<Cloud class="size-4 text-muted-foreground" />
<CloudIcon class="size-4 text-muted-foreground" />
</template>
<Switch
:model-value="true"
Expand Down Expand Up @@ -58,7 +60,7 @@
{{ avatarInitials(row.name) }}
</AvatarFallback>
</Avatar>
<Laptop
<ComputerIcon
v-else
class="size-4 text-muted-foreground"
/>
Expand Down Expand Up @@ -128,7 +130,7 @@ import {
Switch,
toast,
} from '@felinic/ui'
import { Cloud, Laptop } from 'lucide-vue-next'
import { CloudIcon, ComputerIcon } from '@memohai/icon/ui'
import { avatarInitials } from '@/composables/useAvatarInitials'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { useAccountRuntimes, useComputerAccessActions, useComputerAccessGrants } from './use-computer-access'
Expand Down Expand Up @@ -246,6 +248,6 @@ function retry(): void {
}

function goToRuntimes(): void {
void router.push({ name: 'runtimes' })
void router.push({ name: 'runtimes', query: { connect: '1' } })
}
</script>
2 changes: 2 additions & 0 deletions apps/web/src/components/provider-icon/icons.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Component } from 'vue'
import {
Slack,
Anthropic,
Azure,
AzureColor,
Expand Down Expand Up @@ -84,6 +85,7 @@ import {
* The key is the SVG filename without extension (e.g. 'openai', 'deepseek-color').
*/
export const iconMap: Record<string, Component> = {
'slack': Slack,
'openai': Openai,
'anthropic': Anthropic,
'github-copilot': GithubCopilot,
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/provider-icon/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
v-bind="$attrs"
/>
<img
v-else-if="isUrl"
:src="icon"
v-else-if="imageSource"
:src="imageSource"
decoding="sync"
loading="eager"
:width="size"
:height="size"
alt=""
Expand All @@ -20,6 +22,7 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { iconMap } from './icons.ts'
import { providerIconSource } from './preload'

const props = withDefaults(defineProps<{
icon: string
Expand All @@ -34,6 +37,11 @@ const isUrl = computed(() =>
props.icon.startsWith('http://') || props.icon.startsWith('https://'),
)

const source = computed(() => isUrl.value && typeof Image !== 'undefined'
? providerIconSource(props.icon)
: undefined)
const imageSource = computed(() => source.value?.value || '')

const iconComponent = computed<Component | undefined>(() => {
if (isUrl.value) return undefined
return iconMap[props.icon]
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/components/provider-icon/preload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, expect, it, vi } from 'vitest'

const decode = vi.fn<() => Promise<void>>()
const request = vi.fn<typeof fetch>()
class MockImage {
src = ''
decode = decode
}

function setup() {
vi.stubGlobal('Image', MockImage)
vi.stubGlobal('fetch', request)
decode.mockResolvedValue(undefined)
request.mockImplementation(async () => new Response('<svg xmlns="http://www.w3.org/2000/svg"/>', {
headers: { 'Content-Type': 'image/svg+xml' },
}))
}

afterEach(() => {
vi.unstubAllGlobals()
vi.resetModules()
decode.mockReset()
request.mockReset()
})

it('shares pending work and decoded bytes between preload and repeated mounts for any URL', async () => {
setup()
const { providerIconSource, preloadProviderIcons } = await import('./preload')
const url = 'https://custom.example/artwork.svg'
preloadProviderIcons([url, 'slack', undefined])
const first = providerIconSource(url)
expect(providerIconSource(url)).toBe(first)
await vi.waitFor(() => expect(first.value).toMatch(/^data:image\/svg\+xml;base64,/))
for (let i = 0; i < 20; i++) expect(providerIconSource(url).value).toBe(first.value)
expect(request).toHaveBeenCalledTimes(1)
expect(decode).toHaveBeenCalledTimes(1)
})

it('falls back to normal embedding on CORS failure and retries on a later mount', async () => {
setup()
request.mockRejectedValueOnce(new TypeError('Failed to fetch'))
const { providerIconSource } = await import('./preload')
const url = 'https://custom.example/no-cors.png'
const first = providerIconSource(url)
await vi.waitFor(() => expect(first.value).toBe(url))
const second = providerIconSource(url)
await vi.waitFor(() => expect(second.value).toMatch(/^data:/))
expect(request).toHaveBeenCalledTimes(2)
})

it('does not publish an undecodable data source', async () => {
setup()
decode.mockRejectedValueOnce(new Error('Invalid artwork'))
const { providerIconSource } = await import('./preload')
const url = 'https://custom.example/broken.svg'
const source = providerIconSource(url)
await vi.waitFor(() => expect(source.value).toBe(url))
})

it('evicts old cache entries without invalidating sources held by mounted consumers', async () => {
setup()
const { providerIconSource } = await import('./preload')
const first = providerIconSource('https://custom.example/first.svg')
await vi.waitFor(() => expect(first.value).toMatch(/^data:/))
const loaded = first.value
for (let i = 0; i < 128; i++) providerIconSource(`https://custom.example/${i}.svg`)
expect(first.value).toBe(loaded)
expect(providerIconSource('https://custom.example/first.svg')).not.toBe(first)
})
51 changes: 51 additions & 0 deletions apps/web/src/components/provider-icon/preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { shallowRef, type ShallowRef } from 'vue'

// Share the fetched bytes, not just a detached Image that merely warms the
// browser's HTTP cache. A remounted icon can reuse this source without another
// remote resource request. Data URLs need no revocation while a consumer uses
// them; the bounded map limits how many unused sources we retain.
const sources = new Map<string, ShallowRef<string>>()
const maxEntries = 128
const maxBytes = 512 * 1024

export function providerIconSource(url: string): ShallowRef<string> {
const cached = sources.get(url)
if (cached) {
sources.delete(url)
sources.set(url, cached)
return cached
}
const source = shallowRef('')
sources.set(url, source)
if (sources.size > maxEntries) sources.delete(sources.keys().next().value!)
void load(url, source)
return source
}

async function load(url: string, source: ShallowRef<string>): Promise<void> {
try {
const response = await fetch(url)
if (!response.ok) throw new Error('Icon request failed')
const blob = await response.blob()
if (!blob.type.startsWith('image/') || blob.size > maxBytes) throw new Error('Icon cannot be cached')
const bytes = new Uint8Array(await blob.arrayBuffer())
const encoded = btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join(''))
const dataUrl = `data:${blob.type};base64,${encoded}`
const image = new Image()
image.src = dataUrl
await image.decode()
source.value = dataUrl
} catch {
// Some custom hosts permit img embedding but not CORS fetches. Keep those
// working through the original URL and let a later mount retry the cache.
source.value = url
if (sources.get(url) === source) sources.delete(url)
}
}

export function preloadProviderIcons(icons: Iterable<string | undefined>): void {
if (typeof Image === 'undefined') return
for (const icon of icons) {
if (icon && /^https?:\/\//.test(icon)) providerIconSource(icon)
}
}
16 changes: 8 additions & 8 deletions apps/web/src/components/searchable-select-popover/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,10 @@
>
</div>

<div
:id="listboxId"
ref="scrollEl"
:class="virtualListboxClass"
role="listbox"
<MenuScrollArea
ref="scrollElArea"
layout="virtual"
:viewport-attrs="{ id: listboxId, role: 'listbox' }"
>
<div
v-if="rows.length === 0"
Expand Down Expand Up @@ -113,7 +112,7 @@
</button>
</div>
</div>
</div>
</MenuScrollArea>
</div>
</PopoverContent>
</Popover>
Expand All @@ -132,7 +131,7 @@ import {
PopoverTrigger,
PopoverContent,
selectTriggerClass,
virtualListboxClass,
MenuScrollArea,
} from '@felinic/ui'
import { computed, nextTick, ref, useId, watch } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
Expand Down Expand Up @@ -202,7 +201,8 @@ const props = withDefaults(defineProps<{
const selected = defineModel<string>({ default: '' })
const searchTerm = ref('')
const open = ref(false)
const scrollEl = ref<HTMLElement | null>(null)
const scrollElArea = ref<InstanceType<typeof MenuScrollArea> | null>(null)
const scrollEl = computed(() => scrollElArea.value?.viewportElement ?? null)

const selectedOption = computed(() =>
props.options.find((option) => option.value === selected.value),
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/settings-sidebar/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
</template>

<script setup lang="ts">
import { ComputerIcon } from '@memohai/icon/ui'
import { computed, inject, onBeforeUnmount, ref, type Component } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
Expand All @@ -115,7 +116,6 @@ import {
Globe,
Info,
Keyboard,
Laptop,
Mail,
MousePointer2,
Store,
Expand Down Expand Up @@ -247,7 +247,7 @@ const navGroups = computed<NavGroup[]>(() => [
key: 'workspace',
items: [
{ title: t('sidebar.bots'), name: 'bots', icon: MousePointer2, flipX: true },
{ title: t('sidebar.runtimes'), name: 'runtimes', icon: Laptop },
{ title: t('sidebar.runtimes'), name: 'runtimes', icon: ComputerIcon },
{ title: t('sidebar.supermarket'), name: 'supermarket', icon: Store },
],
},
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/sidebar/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ import { computed, onBeforeUnmount, ref, watch, type Component } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { Files, MessageCircle, Search, Settings, Calendar } from 'lucide-vue-next'
import { Files, MessageCircle, Search, Calendar } from 'lucide-vue-next'
import { SettingsIcon as Settings } from '@memohai/icon/ui'
import { BadgeCount, Button } from '@felinic/ui'
import { useChatStore } from '@/store/chat-list'
import { useWorkspaceTabsStore, type SidebarView } from '@/store/workspace-tabs'
Expand Down
Loading