diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 408c7caf5f..0a3f56e336 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -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'` → ``. 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. diff --git a/apps/web/src/components/computer/bot-computer-access-dialog.vue b/apps/web/src/components/computer/bot-computer-access-dialog.vue index ed4f8ff7d9..db393e2404 100644 --- a/apps/web/src/components/computer/bot-computer-access-dialog.vue +++ b/apps/web/src/components/computer/bot-computer-access-dialog.vue @@ -1,6 +1,9 @@ diff --git a/apps/web/src/components/provider-icon/icons.ts b/apps/web/src/components/provider-icon/icons.ts index b7faf28b78..6a0a0da1e9 100644 --- a/apps/web/src/components/provider-icon/icons.ts +++ b/apps/web/src/components/provider-icon/icons.ts @@ -1,5 +1,6 @@ import type { Component } from 'vue' import { + Slack, Anthropic, Azure, AzureColor, @@ -84,6 +85,7 @@ import { * The key is the SVG filename without extension (e.g. 'openai', 'deepseek-color'). */ export const iconMap: Record = { + 'slack': Slack, 'openai': Openai, 'anthropic': Anthropic, 'github-copilot': GithubCopilot, diff --git a/apps/web/src/components/provider-icon/index.vue b/apps/web/src/components/provider-icon/index.vue index 51b270c3b8..681ce4d964 100644 --- a/apps/web/src/components/provider-icon/index.vue +++ b/apps/web/src/components/provider-icon/index.vue @@ -6,8 +6,10 @@ v-bind="$attrs" /> import { computed, type Component } from 'vue' import { iconMap } from './icons.ts' +import { providerIconSource } from './preload' const props = withDefaults(defineProps<{ icon: string @@ -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(() => { if (isUrl.value) return undefined return iconMap[props.icon] diff --git a/apps/web/src/components/provider-icon/preload.test.ts b/apps/web/src/components/provider-icon/preload.test.ts new file mode 100644 index 0000000000..5649f0bc4b --- /dev/null +++ b/apps/web/src/components/provider-icon/preload.test.ts @@ -0,0 +1,69 @@ +import { afterEach, expect, it, vi } from 'vitest' + +const decode = vi.fn<() => Promise>() +const request = vi.fn() +class MockImage { + src = '' + decode = decode +} + +function setup() { + vi.stubGlobal('Image', MockImage) + vi.stubGlobal('fetch', request) + decode.mockResolvedValue(undefined) + request.mockImplementation(async () => new Response('', { + 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) +}) diff --git a/apps/web/src/components/provider-icon/preload.ts b/apps/web/src/components/provider-icon/preload.ts new file mode 100644 index 0000000000..074169b7e7 --- /dev/null +++ b/apps/web/src/components/provider-icon/preload.ts @@ -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>() +const maxEntries = 128 +const maxBytes = 512 * 1024 + +export function providerIconSource(url: string): ShallowRef { + 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): Promise { + 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): void { + if (typeof Image === 'undefined') return + for (const icon of icons) { + if (icon && /^https?:\/\//.test(icon)) providerIconSource(icon) + } +} diff --git a/apps/web/src/components/searchable-select-popover/index.vue b/apps/web/src/components/searchable-select-popover/index.vue index 75d592d044..5be039078a 100644 --- a/apps/web/src/components/searchable-select-popover/index.vue +++ b/apps/web/src/components/searchable-select-popover/index.vue @@ -44,11 +44,10 @@ > -
- + @@ -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' @@ -202,7 +201,8 @@ const props = withDefaults(defineProps<{ const selected = defineModel({ default: '' }) const searchTerm = ref('') const open = ref(false) -const scrollEl = ref(null) +const scrollElArea = ref | null>(null) +const scrollEl = computed(() => scrollElArea.value?.viewportElement ?? null) const selectedOption = computed(() => props.options.find((option) => option.value === selected.value), diff --git a/apps/web/src/components/settings-sidebar/index.vue b/apps/web/src/components/settings-sidebar/index.vue index 4611ed146c..a314d8536f 100644 --- a/apps/web/src/components/settings-sidebar/index.vue +++ b/apps/web/src/components/settings-sidebar/index.vue @@ -100,6 +100,7 @@