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 @@
+
+
+
+
+
+ {{ t('connectors.title') }}
+
+
+
+
+ {{ t('common.loading') }}
+
+
+ {{ t('connectors.loadFailed') }}
+ {{ t('common.retry') }}
+
+
+
+
+
+
+ {{ item.name || item.type }}
+ {{ item.connected ? t('connectors.status.active') : t('connectors.connect') }}
+
+
+
+
+ {{ t('chat.manageConnectors') }}
+
+
+
+
diff --git a/apps/web/src/pages/home/components/composer-continue-on.vue b/apps/web/src/pages/home/components/composer-continue-on.vue
index 5621864b62..160b88cf29 100644
--- a/apps/web/src/pages/home/components/composer-continue-on.vue
+++ b/apps/web/src/pages/home/components/composer-continue-on.vue
@@ -10,7 +10,7 @@
row, so the collapsed circle carries it and the selection is read
in the menu instead.
The two forms are ONE element morphing, never two nodes swapping:
- a single Laptop glyph, a collapsing label slot (max-width/opacity),
+ a single computer glyph, a collapsing label slot (max-width/opacity),
and a padding transition converge the circle to exactly 32×32
(44×44 on mobile). Splitting the forms across v-if/v-else nodes
reads as two different controls mid-switch. The