Skip to content

Commit 67740ed

Browse files
authored
feat(web): 支持聊天内联交付链接与主题网站图标 (#1367)
* feat(web): 支持聊天内联交付链接与主题网站图标 * refactor(web): 网站图标按 origin 探测并缓存,链接悬停改为细点下划线 - /site-icon 只抓取链接所在站点根路径,路径与 query 不再外发;一次返回 light/dark 两套图标 - 服务端按 origin 缓存(命中 24h、未找到 1h、上限 1024)并合并并发请求;只允许 80/443 端口,补充 CGNAT/NAT64/基准测试等特殊网段拦截 - 前端按 origin 共享一次查询,切换主题直接取另一套图标,不再重新请求 - 链接悬停与键盘焦点由实线下划线改为 1px 细点 - 浏览器地址报错文案补上 [::1] * fix(server): 网站图标探测放行 198.18.0.0/15,兼容 fake-IP 代理 Clash、Surge 等 TUN/fake-IP 模式会把所有域名解析到 198.18.0.0/15,拦截该网段会让这类环境下所有外链都拿不到图标。 * fix(site-icon): 超时、5xx 与 429 只短暂缓存 * fix(site-icon): 浏览器缓存时长跟随服务端条目,暂时失败不被钉住 1 小时 * fix(web): 空的站点图标结果不再整页缓存;补充部署出网与托管工作区说明 * refactor(web): 站点图标逻辑拆出 useSiteIcon,删除 GitHub 专用图标规则 - 服务端不再为 GitHub 拼接 data-base-href 深色图标;所有站点按同一套 <link> 声明规则选图标 - 每个 icon <link> 只解析、校验一次 - 缓存键去掉显式默认端口,https://host:443/ 与 https://host/ 共用一条 - md-link 的图标查询与加载状态移入 useSiteIcon - 测试锁定 markstream 注入开关覆盖 LinkNode prop 的行为,防止其弹层与共享 Tooltip 叠加 * feat(web): 单色 SVG 图标对比度不足时按链接色染色 只靠脚本切换主题图标的站点(如 GitHub)在另一主题下只剩一个几乎看不见的单色图标。服务端对单色 SVG 按 WCAG 3:1 判断对比度,不足时附带 data URL 蒙版,前端用链接色填充其形状;多色、位图和本就清晰的图标不变。 * fix(web): 收紧图标探测资源上限,链接交付说明移入工具说明 - 图标 URL 限长 2048,探测整体限时 10s,全局最多 8 个并发 - 私网或非法重定向按确定失败缓存 1h;SVG 判色支持 color/currentColor - useSiteIcon 按当前 src 记录加载结果,避免浏览器规范化 URL 导致不匹配 - 文件链接去掉 ?/# 后再解析路径 - 交付链接写法从静态 mode_chat.md 移到 ContainerProvider.Usage,仅在 Web/Desktop 且有工作区工具时注入
1 parent 0e3c258 commit 67740ed

28 files changed

Lines changed: 1447 additions & 55 deletions

‎apps/web/src/components/markdown/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const sharedComponents: Record<string, Component> = {
1818
footnote_reference: MdFootnoteReference,
1919
footnote_anchor: MdFootnoteAnchor,
2020
text: MdText,
21-
// Intercept clicks on container-local (localhost) links to open the workspace
22-
// browser panel; delegates rendering/external links to markstream's LinkNode.
21+
// Opens workspace file paths and localhost links inside the workspace and
22+
// adds site icons; rendering stays with markstream's LinkNode.
2323
link: MdLink,
2424
}
2525

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
3+
import { TooltipProvider } from '@felinic/ui'
4+
import { createApp, h, nextTick, reactive } from 'vue'
5+
import MarkdownRender from 'markstream-vue'
6+
import { registerSharedMarkdownComponents } from './index'
7+
import { clearSiteIconLookups } from '@/utils/site-icon'
8+
9+
const mocks = vi.hoisted(() => ({ siteIcon: vi.fn(async () => ({ data: { light: 'https://site.example/favicon.svg', dark: 'https://site.example/favicon-dark.svg' } })), openFile: vi.fn(() => true), openBrowserAt: vi.fn(() => true), error: vi.fn() }))
10+
vi.mock('@memohai/sdk', () => ({ getSiteIcon: mocks.siteIcon }))
11+
const settings = reactive({ resolvedColorMode: 'dark' })
12+
vi.mock('@/store/settings', () => ({ useSettingsStore: () => settings }))
13+
vi.mock('@/store/workspace-tabs', () => ({ useWorkspaceTabsStore: () => mocks }))
14+
vi.mock('@felinic/ui', async (importOriginal) => ({
15+
...await importOriginal<typeof import('@felinic/ui')>(), toast: { error: mocks.error },
16+
}))
17+
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
18+
19+
let unmount: (() => void) | undefined
20+
afterEach(() => { unmount?.(); settings.resolvedColorMode = 'dark'; document.body.innerHTML = ''; clearSiteIconLookups(); vi.clearAllMocks() })
21+
22+
async function render(content: string, { showTooltips = false, customId = 'link-test' } = {}) {
23+
registerSharedMarkdownComponents('link-test')
24+
const host = document.createElement('div')
25+
document.body.append(host)
26+
const app = createApp({ render: () => h(TooltipProvider, {}, () => h(MarkdownRender, {
27+
content, customId, final: true, typewriter: false, smoothStreaming: false, showTooltips,
28+
})) })
29+
app.mount(host)
30+
unmount = () => app.unmount()
31+
await nextTick()
32+
await vi.waitFor(() => expect(host.querySelector('a')).not.toBeNull())
33+
return host
34+
}
35+
36+
describe('chat Markdown delivery links', () => {
37+
it('preserves inline rich labels, shows icons and opens each target in its own surface', async () => {
38+
const host = await render('做好了,可以[**打开试玩**](http://localhost:8080/index.html),也可以[查看源文件](/data/counter/index.html)或[查阅官网](https://example.com)。')
39+
const links = host.querySelectorAll('a')
40+
expect(links).toHaveLength(3)
41+
// Link previews must not fall back to the delayed native browser title.
42+
expect(links[0]!.getAttribute('title')).toBeNull()
43+
expect(host.querySelectorAll('svg')).toHaveLength(3)
44+
expect(links[0]!.querySelector('strong')?.textContent).toContain('打开试玩')
45+
links[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
46+
expect(mocks.openBrowserAt).toHaveBeenCalledWith('localhost:8080/index.html')
47+
links[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
48+
expect(mocks.openFile).toHaveBeenCalledWith('/data/counter/index.html')
49+
expect(links[2]!.target).toBe('_blank')
50+
expect(links[2]!.rel).toContain('noopener')
51+
})
52+
it('keeps the renderer tooltip off even when the page enables markstream tooltips', async () => {
53+
// markstream lets an injected setting override LinkNode's prop; if that
54+
// stops holding, its popover opens next to the shared tooltip. An open
55+
// popover marks the anchor with aria-describedby. The stock renderer opens
56+
// it first, proving the check can see it and loading the lazily mounted
57+
// popover, so the second check only has to pass its 80ms open delay.
58+
const hover = new MouseEvent('mouseenter', { clientX: 1, clientY: 1 })
59+
let anchor = (await render('[官网](https://site.example/docs)', { showTooltips: true, customId: 'stock' })).querySelector('a')!
60+
anchor.dispatchEvent(hover)
61+
await vi.waitFor(() => expect(anchor.getAttribute('aria-describedby')).not.toBeNull())
62+
unmount?.()
63+
anchor = (await render('[官网](https://site.example/docs)', { showTooltips: true })).querySelector('a')!
64+
vi.useFakeTimers()
65+
try {
66+
anchor.dispatchEvent(hover)
67+
await vi.advanceTimersByTimeAsync(200)
68+
} finally {
69+
vi.useRealTimers()
70+
}
71+
expect(anchor.getAttribute('aria-describedby')).toBeNull()
72+
expect(anchor.getAttribute('title')).toBeNull()
73+
})
74+
it('uses a loaded site icon and falls back when the icon is unavailable', async () => {
75+
const host = await render('[站点](https://site.example/path?q=private) [预览](http://localhost:8080/)')
76+
await vi.waitFor(() => expect(host.querySelector('img')).not.toBeNull())
77+
// Only the origin leaves the browser; the shared path and query stay private.
78+
expect(mocks.siteIcon).toHaveBeenCalledWith({ query: { url: 'https://site.example' } })
79+
const image = host.querySelector('img')!
80+
expect(image.src).toBe('https://site.example/favicon-dark.svg')
81+
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
82+
expect(host.querySelectorAll('img')).toHaveLength(1)
83+
image.dispatchEvent(new Event('load'))
84+
await nextTick()
85+
expect(image.classList.contains('invisible')).toBe(false)
86+
expect(host.querySelectorAll('svg')).toHaveLength(1)
87+
image.dispatchEvent(new Event('error'))
88+
await nextTick()
89+
expect(host.querySelector('img')).toBeNull()
90+
})
91+
it('looks up each site once and switches icons with the theme without refetching', async () => {
92+
const host = await render('[a](https://site.example/a) [b](https://site.example/b?x=1) [c](https://site.example)')
93+
await vi.waitFor(() => expect(host.querySelectorAll('img')).toHaveLength(3))
94+
expect(mocks.siteIcon).toHaveBeenCalledTimes(1)
95+
settings.resolvedColorMode = 'light'
96+
await vi.waitFor(() => expect(host.querySelector('img')?.src).toBe('https://site.example/favicon.svg'))
97+
expect(host.querySelector('img')?.style.colorScheme).toBe('light')
98+
expect(mocks.siteIcon).toHaveBeenCalledTimes(1)
99+
})
100+
it('asks again for a site whose lookup came back empty', async () => {
101+
mocks.siteIcon.mockResolvedValueOnce({ data: { light: '', dark: '' } })
102+
let host = await render('[a](https://site.example/a)')
103+
await vi.waitFor(() => expect(mocks.siteIcon).toHaveBeenCalledTimes(1))
104+
expect(host.querySelector('img')).toBeNull()
105+
unmount?.()
106+
host = await render('[a](https://site.example/a)')
107+
await vi.waitFor(() => expect(host.querySelector('img')).not.toBeNull())
108+
expect(mocks.siteIcon).toHaveBeenCalledTimes(2)
109+
})
110+
it('keeps modifier and middle clicks in the workspace instead of navigating to the host', async () => {
111+
const host = await render('[文件](/data/a%20b.md)')
112+
const anchor = host.querySelector('a')!
113+
for (const event of [new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }), new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 })]) {
114+
anchor.dispatchEvent(event)
115+
expect(event.defaultPrevented).toBe(true)
116+
}
117+
expect(mocks.openFile).toHaveBeenCalledWith('/data/a b.md')
118+
})
119+
it('reports an unavailable preview without falling back to the user localhost', async () => {
120+
const host = await render('[预览](http://localhost:8080/)')
121+
mocks.openBrowserAt.mockReturnValueOnce(false)
122+
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
123+
host.querySelector('a')!.dispatchEvent(event)
124+
expect(event.defaultPrevented).toBe(true)
125+
expect(mocks.error).toHaveBeenCalledWith('chat.workspaceLinkUnavailable')
126+
})
127+
})
Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,91 @@
11
<script setup lang="ts">
2-
import { useAttrs } from 'vue'
2+
import { computed, provide, reactive, useAttrs } from 'vue'
3+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@felinic/ui'
4+
import { FileText, Globe } from 'lucide-vue-next'
35
import { LinkNode, type LinkNodeProps } from 'markstream-vue'
6+
import { useSiteIcon } from '@/composables/useSiteIcon'
47
import { useWorkspaceLink } from '@/composables/useWorkspaceLink'
8+
import { classifyWorkspaceLink } from '@/utils/workspace-link'
9+
import { siteIconOrigin } from '@/utils/site-icon'
510
6-
// Custom markstream `link` node. Rendering is delegated wholesale to markstream's
7-
// own LinkNode (so styling, tooltip and stream animation are untouched); we only
8-
// hijack the CLICK for links that point at a container-local dev server, opening
9-
// them in the workspace browser panel instead of the user's OS browser — the
10-
// container's localhost is not the user's localhost. Everything else (external
11-
// links, modifier-clicks) keeps default behavior.
11+
// Keep markstream's rich inline text, URL sanitization and streaming
12+
// behavior. The leading icon sits over the anchor's padding so it shares the
13+
// same click target without replacing the renderer or adding a second link.
1214
defineOptions({ inheritAttrs: false })
1315
1416
const props = defineProps<{
1517
node: LinkNodeProps['node']
1618
indexKey: LinkNodeProps['indexKey']
1719
}>()
1820
21+
// The shared Memoh tooltip owns link hints. markstream's injected
22+
// `markstreamShowTooltips` overrides LinkNode's own prop, so disabling its
23+
// popover has to go through the same key. With the popover off, LinkNode puts
24+
// the URL in a native title instead (attrs cannot override it); remove it, or
25+
// it shows a second, delayed hint.
26+
provide('markstreamShowTooltips', computed(() => false))
27+
function removeNativeTitle(element: HTMLElement) {
28+
element.removeAttribute('title')
29+
}
30+
const vWithoutNativeTitle = { mounted: removeNativeTitle, updated: removeNativeTitle }
31+
1932
const attrs = useAttrs()
2033
const openWorkspaceLink = useWorkspaceLink()
34+
const link = computed(() => props.node.loading ? null : classifyWorkspaceLink(props.node.href))
35+
const icon = computed(() => link.value?.kind === 'file' ? FileText : Globe)
36+
const favicon = reactive(useSiteIcon(() => link.value?.kind === 'external' ? siteIconOrigin(props.node.href) : null))
2137
</script>
2238

2339
<template>
24-
<span @click.capture="openWorkspaceLink($event, props.node.href)"><LinkNode
25-
:node="node"
26-
:index-key="indexKey"
27-
v-bind="attrs"
28-
/></span>
40+
<TooltipProvider :delay-duration="0">
41+
<Tooltip
42+
:delay-duration="0"
43+
:disabled="node.loading"
44+
>
45+
<TooltipTrigger as-child>
46+
<span
47+
class="relative inline-flex align-baseline text-primary"
48+
:class="{ 'mx-1': link }"
49+
@click.capture="openWorkspaceLink($event, props.node.href)"
50+
@auxclick.capture="openWorkspaceLink($event, props.node.href)"
51+
>
52+
<component
53+
:is="icon"
54+
v-if="link && !favicon.ready"
55+
class="pointer-events-none absolute left-0 top-1/2 size-4 -translate-y-1/2"
56+
aria-hidden="true"
57+
/>
58+
<span
59+
v-if="favicon.mask"
60+
class="pointer-events-none absolute left-0 top-1/2 size-4 -translate-y-1/2 bg-current mask-contain mask-center mask-no-repeat"
61+
:style="{ maskImage: `url(${favicon.mask})` }"
62+
aria-hidden="true"
63+
/>
64+
<img
65+
v-else-if="favicon.usable"
66+
:key="favicon.src"
67+
:src="favicon.src"
68+
:style="{ colorScheme: favicon.colorScheme }"
69+
alt=""
70+
aria-hidden="true"
71+
referrerpolicy="no-referrer"
72+
loading="lazy"
73+
class="pointer-events-none absolute left-0 top-1/2 size-4 -translate-y-1/2 object-contain"
74+
:class="{ invisible: !favicon.ready }"
75+
@load="favicon.onLoad"
76+
@error="favicon.onError"
77+
>
78+
<LinkNode
79+
v-without-native-title
80+
:node="node"
81+
:index-key="indexKey"
82+
v-bind="attrs"
83+
:class="{ 'pl-4.5': link }"
84+
:target="link?.kind === 'external' ? '_blank' : undefined"
85+
/>
86+
</span>
87+
</TooltipTrigger>
88+
<TooltipContent>{{ node.title || node.href }}</TooltipContent>
89+
</Tooltip>
90+
</TooltipProvider>
2991
</template>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
2+
import type { HandlersSiteIconResponse } from '@memohai/sdk'
3+
import { useSettingsStore } from '@/store/settings'
4+
import { lookupSiteIcon } from '@/utils/site-icon'
5+
6+
// Resolves the favicon for one link. `ready` stays false until the image has
7+
// actually decoded, so callers keep their placeholder icon instead of showing
8+
// an empty gap while a slow favicon loads or after it fails.
9+
export function useSiteIcon(origin: MaybeRefOrGetter<string | null>) {
10+
const settings = useSettingsStore()
11+
const icons = ref<HandlersSiteIconResponse | null>(null)
12+
watch(() => toValue(origin), async (value, _, onCleanup) => {
13+
icons.value = null
14+
if (!value) return
15+
let stale = false
16+
onCleanup(() => { stale = true })
17+
const result = await lookupSiteIcon(value)
18+
if (!stale) icons.value = result
19+
}, { immediate: true })
20+
21+
const colorScheme = computed(() => settings.resolvedColorMode)
22+
const src = computed(() => icons.value?.[colorScheme.value] || '')
23+
// A one-color icon that would vanish on this scheme comes back as a mask,
24+
// painted in the link color; it is inline, so there is nothing to wait for.
25+
const mask = computed(() => (colorScheme.value === 'dark' ? icons.value?.dark_mask : icons.value?.light_mask) || '')
26+
// Keyed by src, so a theme switch to the other icon starts from "not loaded".
27+
// The <img> is keyed by src too, so an event always belongs to the current
28+
// src; recording the element's own src would compare the browser-normalized
29+
// URL (lowercased host, dropped :443, encoded spaces) against the raw one.
30+
const loaded = ref('')
31+
const failed = ref('')
32+
return {
33+
src,
34+
mask,
35+
colorScheme,
36+
usable: computed(() => !mask.value && !!src.value && failed.value !== src.value),
37+
ready: computed(() => !!mask.value || (!!src.value && loaded.value === src.value && failed.value !== src.value)),
38+
onLoad: () => { loaded.value = src.value },
39+
onError: () => { failed.value = src.value },
40+
}
41+
}
Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1+
import { toast } from '@felinic/ui'
2+
import { useI18n } from 'vue-i18n'
13
import { useWorkspaceTabsStore } from '@/store/workspace-tabs'
2-
import { tryParseLocalhostHref } from '@/utils/localhost-link'
4+
import { classifyWorkspaceLink } from '@/utils/workspace-link'
35

4-
// Markdown and plain-text questions share the same workspace URL routing.
6+
// Workspace addresses cannot be opened by the user's OS browser, including
7+
// modifier/middle clicks. Keep them in their owning workspace or explain why
8+
// that workspace is unavailable instead of silently navigating elsewhere.
59
export function useWorkspaceLink() {
610
const tabs = useWorkspaceTabsStore()
11+
const { t } = useI18n()
712
return (event: MouseEvent, href: string) => {
8-
const parsed = tryParseLocalhostHref(href)
9-
if (!parsed || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
13+
const link = classifyWorkspaceLink(href)
14+
if (!link || link.kind === 'external' || event.button > 1) return
1015
event.preventDefault()
11-
if (!tabs.openBrowserAt(parsed.display)) {
12-
window.open(href, '_blank', 'noopener')
13-
}
16+
const opened = link.kind === 'file'
17+
? tabs.openFile(link.path)
18+
: tabs.openBrowserAt(link.address)
19+
if (!opened) toast.error(t('chat.workspaceLinkUnavailable'))
1420
}
1521
}

‎apps/web/src/i18n/locales/en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,7 @@
717717
"license": "AGPLv3 · © 2026 MemohAI"
718718
},
719719
"chat": {
720+
"workspaceLinkUnavailable": "This workspace link is unavailable here. Open the conversation in Memoh with workspace access.",
720721
"runtimeStatus": {
721722
"compacting": "Compacting context…",
722723
"api_retry": "Retry {attempt}/{max_retries} in {seconds}s…"

‎apps/web/src/i18n/locales/ja.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,7 @@
714714
"license": "AGPLv3 · © 2026 MemohAI"
715715
},
716716
"chat": {
717+
"workspaceLinkUnavailable": "ここではワークスペースのリンクを開けません。ワークスペースへのアクセス権がある Memoh の会話で開いてください。",
717718
"runtimeStatus": {
718719
"compacting": "コンテキストを圧縮中…",
719720
"api_retry": "{seconds} 秒後に再試行({attempt}/{max_retries})…"

‎apps/web/src/i18n/locales/zh.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,7 @@
717717
"license": "AGPLv3 · © 2026 MemohAI"
718718
},
719719
"chat": {
720+
"workspaceLinkUnavailable": "此处无法打开工作区链接,请在有工作区访问权限的 Memoh 对话中打开。",
720721
"runtimeStatus": {
721722
"compacting": "正在压缩上下文…",
722723
"api_retry": "{seconds} 秒后重试({attempt}/{max_retries})…"

‎apps/web/src/store/workspace-tabs.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,16 +1347,17 @@ export const useWorkspaceTabsStore = defineStore('workspace-tabs', () => {
13471347
})
13481348
}
13491349

1350-
function openFile(filePath: string) {
1351-
if (!hasCurrentPermission('workspace_read')) return
1350+
function openFile(filePath: string): boolean {
1351+
if (!hasCurrentPermission('workspace_read') || !api.value) return false
13521352
const path = (filePath ?? '').trim()
1353-
if (!path) return
1353+
if (!path) return false
13541354
openEphemeral({
13551355
id: `file:${path}`,
13561356
component: 'file',
13571357
title: fileBaseName(path),
13581358
params: { filePath: path },
13591359
})
1360+
return true
13601361
}
13611362

13621363
// Open a file as a PINNED tab in the active editor group. This is the

0 commit comments

Comments
 (0)