From 53588c5aebcf6bcba4ce355dd9a4a94acaa7f199 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Mon, 3 Aug 2026 00:21:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(hub,web):=20HTML=20preview=20=E2=80=94=20r?= =?UTF-8?q?aw=20file=20endpoint=20+=20viewer=20toggle=20+=20inline=20chat?= =?UTF-8?q?=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/src/web/middleware/auth.ts | 4 +- hub/src/web/routes/git.ts | 38 ++++++++++++++ .../components/assistant-ui/markdown-text.tsx | 50 ++++++++++++++++--- web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/routes/sessions/file.tsx | 36 ++++++++++++- 6 files changed, 123 insertions(+), 9 deletions(-) diff --git a/hub/src/web/middleware/auth.ts b/hub/src/web/middleware/auth.ts index 175d7d23..dfeb2b5b 100644 --- a/hub/src/web/middleware/auth.ts +++ b/hub/src/web/middleware/auth.ts @@ -24,7 +24,9 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler SyncEngine | null): Hono which cannot send Authorization headers. + app.get('/sessions/:id/file/raw', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const sessionPath = sessionResult.session.metadata?.path + if (!sessionPath) { + return c.json({ success: false, error: 'Session path not available' }) + } + + const parsed = filePathSchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ error: 'Invalid file path' }, 400) + } + + const result = await runRpc(() => engine.readSessionFile(sessionResult.sessionId, parsed.data.path)) + if (!result.success || typeof result.content !== 'string') { + return c.json({ success: false, error: result.error ?? 'Failed to read file' }, 404) + } + + const bytes = Uint8Array.from(Buffer.from(result.content, 'base64')) + const isHtml = /\.html?$/i.test(parsed.data.path) + return c.body(bytes, 200, { + 'Content-Type': isHtml ? 'text/html; charset=utf-8' : 'application/octet-stream', + 'Content-Disposition': 'inline', + 'Cache-Control': 'private, max-age=60' + }) + }) + app.get('/sessions/:id/generated-images/:imageId', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index ff709e7b..36529048 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -23,6 +23,7 @@ import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { useCodeWrap } from '@/hooks/useCodeWrap' import { CopyIcon, CheckIcon, WrapIcon } from '@/components/icons' +import { useAppContext } from '@/lib/app-context' import { useTranslation } from '@/lib/use-translation' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' @@ -495,9 +496,16 @@ function Code(props: ComponentPropsWithoutRef<'code'>) { function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) { const navigate = useNavigate() + const { t } = useTranslation() + const { baseUrl, token } = useAppContext() + const [showPreview, setShowPreview] = useState(false) const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel const search = new URLSearchParams({ path: encodeBase64(props.filePath) }).toString() const href = `/sessions/${encodeURIComponent(props.sessionId)}/file?${search}` + const isHtml = /\.html?$/i.test(props.filePath) + const previewUrl = isHtml && token + ? `${baseUrl}/api/sessions/${encodeURIComponent(props.sessionId)}/file/raw?path=${encodeURIComponent(props.filePath)}&token=${encodeURIComponent(token)}` + : null const handleClick = (event: MouseEvent) => { props.onClick?.(event) @@ -513,13 +521,41 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin } return ( - + <> + + {previewUrl ? ( + <> + {' '} + + {showPreview ? ( + +