mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(hub,web): HTML preview — raw file endpoint + viewer toggle + inline chat preview
This commit is contained in:
@@ -24,7 +24,9 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler<W
|
||||
|
||||
const authorization = c.req.header('authorization')
|
||||
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
|
||||
|
||||
if (!token) {
|
||||
|
||||
@@ -157,6 +157,44 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono<We
|
||||
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) => {
|
||||
const engine = requireSyncEngine(c, getSyncEngine)
|
||||
if (engine instanceof Response) {
|
||||
|
||||
@@ -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<HTMLAnchorElement>) => {
|
||||
props.onClick?.(event)
|
||||
@@ -513,13 +521,41 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
rel={rel}
|
||||
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)}
|
||||
/>
|
||||
<>
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
rel={rel}
|
||||
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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -320,6 +320,8 @@ export default {
|
||||
|
||||
// Chat
|
||||
'chat.placeholder': 'Type a message…',
|
||||
'chat.filePreview': 'Preview',
|
||||
'chat.filePreviewClose': 'Close preview',
|
||||
'chat.send': 'Send',
|
||||
'chat.abort': 'Abort',
|
||||
'chat.settings': 'Settings',
|
||||
|
||||
@@ -324,6 +324,8 @@ export default {
|
||||
|
||||
// Chat
|
||||
'chat.placeholder': '输入消息…',
|
||||
'chat.filePreview': '预览效果',
|
||||
'chat.filePreviewClose': '收起预览',
|
||||
'chat.send': '发送',
|
||||
'chat.abort': '中止',
|
||||
'chat.settings': '设置',
|
||||
|
||||
@@ -171,7 +171,7 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
|
||||
}
|
||||
|
||||
export default function FilePage() {
|
||||
const { api } = useAppContext()
|
||||
const { api, baseUrl, token } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
const { copied: pathCopied, copy: copyPath } = 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 imageMimeType = useMemo(() => resolveImageMimeType(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({
|
||||
queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged),
|
||||
@@ -224,6 +229,9 @@ export default function FilePage() {
|
||||
const imagePreviewUrl = fileContentResult?.success && fileContentResult.content && imageMimeType
|
||||
? `data:${imageMimeType};base64,${fileContentResult.content}`
|
||||
: 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 [markdownMode, setMarkdownMode] = useState<MarkdownPreviewMode>(getInitialMarkdownPreviewMode)
|
||||
@@ -355,6 +363,25 @@ export default function FilePage() {
|
||||
</button>
|
||||
</>
|
||||
) : 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>
|
||||
) : null}
|
||||
@@ -387,6 +414,13 @@ export default function FilePage() {
|
||||
<div className="text-sm text-[var(--app-hint)]">
|
||||
{t('file.page.binary')}
|
||||
</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 ? (
|
||||
markdownFile && !showMarkdownSource ? (
|
||||
|
||||
Reference in New Issue
Block a user