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 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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '设置',
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user