feat(hub,web): HTML preview — raw file endpoint + viewer toggle + inline chat preview

This commit is contained in:
2026-08-03 00:21:44 +08:00
parent ceeff60235
commit 53588c5aeb
6 changed files with 123 additions and 9 deletions
+3 -1
View File
@@ -24,7 +24,9 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler<W
const authorization = c.req.header('authorization') const authorization = c.req.header('authorization')
const tokenFromHeader = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined const tokenFromHeader = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined
const tokenFromQuery = path === '/api/events' ? c.req.query().token : undefined const tokenFromQuery = (path === '/api/events' || path.endsWith('/file/raw'))
? c.req.query().token
: undefined
const token = tokenFromHeader ?? tokenFromQuery const token = tokenFromHeader ?? tokenFromQuery
if (!token) { if (!token) {
+38
View File
@@ -157,6 +157,44 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono<We
return c.json(result) return c.json(result)
}) })
// Raw file bytes with a browser-friendly Content-Type, for iframe previews
// (e.g. rendering HTML files). Auth via the JWT query token so it works
// inside <iframe src> 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) => { app.get('/sessions/:id/generated-images/:imageId', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine) const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) { if (engine instanceof Response) {
@@ -23,6 +23,7 @@ import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { useCodeWrap } from '@/hooks/useCodeWrap' import { useCodeWrap } from '@/hooks/useCodeWrap'
import { CopyIcon, CheckIcon, WrapIcon } from '@/components/icons' import { CopyIcon, CheckIcon, WrapIcon } from '@/components/icons'
import { useAppContext } from '@/lib/app-context'
import { useTranslation } from '@/lib/use-translation' import { useTranslation } from '@/lib/use-translation'
import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context'
import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' 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 }) { function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) {
const navigate = useNavigate() 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 rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
const search = new URLSearchParams({ path: encodeBase64(props.filePath) }).toString() const search = new URLSearchParams({ path: encodeBase64(props.filePath) }).toString()
const href = `/sessions/${encodeURIComponent(props.sessionId)}/file?${search}` 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<HTMLAnchorElement>) => { const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
props.onClick?.(event) props.onClick?.(event)
@@ -513,13 +521,41 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin
} }
return ( return (
<a <>
{...props} <a
href={href} {...props}
rel={rel} href={href}
onClick={handleClick} rel={rel}
className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)} onClick={handleClick}
/> className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)}
/>
{previewUrl ? (
<>
{' '}
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setShowPreview((value) => !value)
}}
className="rounded border border-[var(--app-border)] px-1.5 py-0.5 align-middle text-[11px] text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)]"
>
{showPreview ? t('chat.filePreviewClose') : t('chat.filePreview')}
</button>
{showPreview ? (
<span className="block">
<iframe
src={previewUrl}
title={props.filePath}
sandbox="allow-scripts"
className="mt-1 h-64 w-full rounded-md border border-[var(--app-border)] bg-white"
/>
</span>
) : null}
</>
) : null}
</>
) )
} }
+2
View File
@@ -320,6 +320,8 @@ export default {
// Chat // Chat
'chat.placeholder': 'Type a message…', 'chat.placeholder': 'Type a message…',
'chat.filePreview': 'Preview',
'chat.filePreviewClose': 'Close preview',
'chat.send': 'Send', 'chat.send': 'Send',
'chat.abort': 'Abort', 'chat.abort': 'Abort',
'chat.settings': 'Settings', 'chat.settings': 'Settings',
+2
View File
@@ -324,6 +324,8 @@ export default {
// Chat // Chat
'chat.placeholder': '输入消息…', 'chat.placeholder': '输入消息…',
'chat.filePreview': '预览效果',
'chat.filePreviewClose': '收起预览',
'chat.send': '发送', 'chat.send': '发送',
'chat.abort': '中止', 'chat.abort': '中止',
'chat.settings': '设置', 'chat.settings': '设置',
+35 -1
View File
@@ -171,7 +171,7 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
} }
export default function FilePage() { export default function FilePage() {
const { api } = useAppContext() const { api, baseUrl, token } = useAppContext()
const { t } = useTranslation() const { t } = useTranslation()
const { copied: pathCopied, copy: copyPath } = useCopyToClipboard() const { copied: pathCopied, copy: copyPath } = useCopyToClipboard()
const { copied: contentCopied, copy: copyContent } = useCopyToClipboard() const { copied: contentCopied, copy: copyContent } = useCopyToClipboard()
@@ -185,6 +185,11 @@ export default function FilePage() {
const fileName = filePath.split('/').pop() || filePath || t('file.page.fallbackName') const fileName = filePath.split('/').pop() || filePath || t('file.page.fallbackName')
const imageMimeType = useMemo(() => resolveImageMimeType(filePath), [filePath]) const imageMimeType = useMemo(() => resolveImageMimeType(filePath), [filePath])
const markdownFile = useMemo(() => isMarkdownFile(filePath), [filePath]) const markdownFile = useMemo(() => isMarkdownFile(filePath), [filePath])
const htmlFile = useMemo(() => /\.html?$/i.test(filePath), [filePath])
const [htmlPreview, setHtmlPreview] = useState(false)
useEffect(() => {
setHtmlPreview(false)
}, [filePath])
const diffQuery = useQuery({ const diffQuery = useQuery({
queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged), queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged),
@@ -224,6 +229,9 @@ export default function FilePage() {
const imagePreviewUrl = fileContentResult?.success && fileContentResult.content && imageMimeType const imagePreviewUrl = fileContentResult?.success && fileContentResult.content && imageMimeType
? `data:${imageMimeType};base64,${fileContentResult.content}` ? `data:${imageMimeType};base64,${fileContentResult.content}`
: null : null
const htmlPreviewUrl = htmlFile && token
? `${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/file/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}`
: null
const language = useMemo(() => imageMimeType ? undefined : resolveLanguage(filePath), [filePath, imageMimeType]) const language = useMemo(() => imageMimeType ? undefined : resolveLanguage(filePath), [filePath, imageMimeType])
const [markdownMode, setMarkdownMode] = useState<MarkdownPreviewMode>(getInitialMarkdownPreviewMode) const [markdownMode, setMarkdownMode] = useState<MarkdownPreviewMode>(getInitialMarkdownPreviewMode)
@@ -355,6 +363,25 @@ export default function FilePage() {
</button> </button>
</> </>
) : null} ) : null}
{htmlFile && displayMode === 'file' ? (
<>
{diffContent ? <span className="mx-1 h-4 w-px bg-[var(--app-divider)]" aria-hidden="true" /> : null}
<button
type="button"
onClick={() => setHtmlPreview(false)}
className={`rounded px-3 py-1 text-xs font-semibold ${!htmlPreview ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.source')}
</button>
<button
type="button"
onClick={() => setHtmlPreview(true)}
className={`rounded px-3 py-1 text-xs font-semibold ${htmlPreview ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.preview')}
</button>
</>
) : null}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -387,6 +414,13 @@ export default function FilePage() {
<div className="text-sm text-[var(--app-hint)]"> <div className="text-sm text-[var(--app-hint)]">
{t('file.page.binary')} {t('file.page.binary')}
</div> </div>
) : htmlFile && htmlPreview && htmlPreviewUrl ? (
<iframe
src={htmlPreviewUrl}
title={fileName}
sandbox="allow-scripts"
className="h-[70vh] w-full rounded-md border border-[var(--app-border)] bg-white"
/>
) : ( ) : (
decodedContent ? ( decodedContent ? (
markdownFile && !showMarkdownSource ? ( markdownFile && !showMarkdownSource ? (