feat(desktop): render AI-output videos inline as <video> (mp4/webm/mov)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 18:23:39 +08:00
parent 7d07a7cadc
commit d7d7cffddb
6 changed files with 206 additions and 4 deletions

View File

@ -111,6 +111,23 @@ describe('AssistantMessage output-target cards', () => {
expect(screen.queryByText('assistantOutputs.kind.image')).toBeNull()
})
it('renders a relative video inline (a <video>) but NOT as a card', () => {
const { container } = render(
<AssistantMessage
sessionId="s1"
content={'生成的视频见 outputs/demo.mp4'}
isStreaming={false}
/>,
)
// Video renders inline through InlineVideoGallery (workDir is undefined in this
// test's mock, so the relative path resolves as-is and is served via /preview-fs).
const video = container.querySelector('video') as HTMLVideoElement
expect(video).not.toBeNull()
expect(video.getAttribute('src')).toBe('http://127.0.0.1:4321/preview-fs/s1/outputs/demo.mp4')
// ...and is NOT duplicated as an output-target card (no extra open/copy controls).
expect(screen.queryByText('assistantOutputs.kind.image')).toBeNull()
})
it('still renders md/html/localhost cards when those references are present', () => {
render(
<AssistantMessage

View File

@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent } from 'react'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
import { InlineImageGallery } from './InlineImageGallery'
import { InlineVideoGallery } from './InlineVideoGallery'
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
import { handlePreviewLink } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
@ -50,8 +51,10 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
() =>
isStreaming || !sessionId
? []
: // Image targets render inline via <InlineImageGallery>; never also as a card.
extractAssistantOutputTargets(content, { workDir }).filter((target) => target.kind !== 'image'),
: // Image/video targets render inline (InlineImageGallery/InlineVideoGallery); never also as a card.
extractAssistantOutputTargets(content, { workDir }).filter(
(target) => target.kind !== 'image' && target.kind !== 'video',
),
[content, isStreaming, sessionId, workDir],
)
@ -80,6 +83,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
onLinkClick={sessionId ? handleLinkClick : undefined}
/>
{!isStreaming && <InlineImageGallery text={content} sessionId={sessionId} workDir={workDir} />}
{!isStreaming && <InlineVideoGallery text={content} sessionId={sessionId} workDir={workDir} />}
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
)}

View File

@ -0,0 +1,60 @@
import '@testing-library/jest-dom'
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
// getServerBaseUrl backs the relative-path src (/preview-fs/<sessionId>/...).
vi.mock('../../lib/desktopRuntime', () => ({
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
import { InlineVideoGallery } from './InlineVideoGallery'
function videoSrcs(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll('video')).map((v) => v.getAttribute('src') ?? '')
}
describe('InlineVideoGallery', () => {
it('renders a relative workspace video via previewFsUrl when sessionId is provided', () => {
const { container } = render(
<InlineVideoGallery text={'render saved to outputs/demo.mp4'} sessionId="s1" workDir="/w" />,
)
const srcs = videoSrcs(container)
expect(srcs).toHaveLength(1)
expect(srcs[0]).toBe('http://127.0.0.1:4321/preview-fs/s1/outputs/demo.mp4')
})
it('uses preload="metadata" and never autoplays', () => {
const { container } = render(
<InlineVideoGallery text={'clip at outputs/demo.mp4'} sessionId="s1" workDir="/w" />,
)
const video = container.querySelector('video')!
expect(video).toHaveAttribute('preload', 'metadata')
expect(video).not.toHaveAttribute('autoplay')
expect(video).not.toHaveAttribute('loop')
})
it('renders nothing when sessionId is absent', () => {
const { container } = render(<InlineVideoGallery text={'clip at outputs/demo.mp4'} />)
expect(container.querySelectorAll('video')).toHaveLength(0)
})
it('renders nothing when there are no video paths', () => {
const { container } = render(
<InlineVideoGallery text={'just some text and an image outputs/a.png'} sessionId="s1" workDir="/w" />,
)
expect(container.querySelectorAll('video')).toHaveLength(0)
})
it('deduplicates a repeated video path', () => {
const { container } = render(
<InlineVideoGallery
text={'see outputs/demo.mp4 and again outputs/demo.mp4'}
sessionId="s1"
workDir="/w"
/>,
)
expect(container.querySelectorAll('video')).toHaveLength(1)
})
})

View File

@ -0,0 +1,84 @@
import { useMemo } from 'react'
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
import { previewFsUrl } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
type GalleryVideo = {
src: string
name: string
}
type Props = {
text: string
/**
* Required to build a `/preview-fs/<sessionId>/...` URL. When absent (e.g.
* tool-log usage) nothing renders relative workspace videos can't be served
* without a session, and we deliberately keep media out of tool logs.
*/
sessionId?: string
workDir?: string | null
}
/**
* Renders AI-output video paths (mp4/webm/mov/m4v) inline, mirroring
* {@link InlineImageGallery}. Only relative workspace paths are surfaced (via the
* sandboxed target extractor + `/preview-fs`); videos are large so we use a
* vertical stack, `preload="metadata"`, and never autoplay.
*/
export function InlineVideoGallery({ text, sessionId, workDir }: Props) {
const videos = useMemo<GalleryVideo[]>(() => {
if (!sessionId) {
return []
}
const base = getServerBaseUrl()
const targets = extractAssistantOutputTargets(text, { workDir }).filter(
(target) => target.kind === 'video',
)
const seenSrc = new Set<string>()
const result: GalleryVideo[] = []
for (const target of targets) {
const relPath = target.normalizedPath ?? target.href
const src = previewFsUrl(base, sessionId, relPath)
if (seenSrc.has(src)) {
continue
}
seenSrc.add(src)
result.push({ src, name: relPath.split('/').pop() ?? '' })
}
return result
}, [sessionId, text, workDir])
if (videos.length === 0) return null
return (
<div className="mt-3 space-y-2">
{videos.map((video) => (
<div
key={video.src}
className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-sm"
>
<video
src={video.src}
controls
preload="metadata"
playsInline
className="w-full rounded-t-xl bg-black"
style={{ maxHeight: 420 }}
onError={(e) => {
// Hide the whole container when the video can't be loaded.
(e.target as HTMLVideoElement).closest('div')!.style.display = 'none'
}}
/>
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[12px]">movie</span>
<span className="truncate">{video.name}</span>
</div>
</div>
))}
</div>
)
}

View File

@ -21,6 +21,38 @@ describe('extractAssistantOutputTargets', () => {
])
})
it('detects a naked relative video path as a video target', () => {
const targets = extractAssistantOutputTargets('渲染完成,见 outputs/clip.mp4 。', { workDir })
expect(targets).toMatchObject([
{
kind: 'video',
href: 'outputs/clip.mp4',
normalizedPath: 'outputs/clip.mp4',
source: 'plain-path',
},
])
})
it('detects a markdown link to a video as a video target', () => {
const targets = extractAssistantOutputTargets('[v](demo.webm)', { workDir })
expect(targets).toMatchObject([
{
kind: 'video',
href: 'demo.webm',
normalizedPath: 'demo.webm',
source: 'markdown-link',
},
])
})
it('rejects a video path outside the active workspace (sandbox)', () => {
const targets = extractAssistantOutputTargets('[bad](/etc/x.mp4)', { workDir })
expect(targets).toEqual([])
})
it('normalizes markdown destinations with angle brackets, spaces, and line suffixes', () => {
const content = [
'[html](</Users/nanmi/project/demo/My Page/index.html>)',

View File

@ -2,6 +2,7 @@ export type AssistantOutputTargetKind =
| 'local-html'
| 'localhost-url'
| 'image'
| 'video'
| 'markdown'
export type AssistantOutputTargetSource =
@ -64,7 +65,7 @@ type DirectoryTreeFileMatch = {
const localhostUrlPattern =
/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s`"'<>,。;、)\])}]*)?/gi
const previewablePathPattern =
/(^|[\s("'`[])((?:\.{1,2}\/)?(?:[\w.-]+\/)*[\w.-]+\.(?:html?|md|markdown|png|jpe?g|gif|webp|svg))(?![\w/-])/gi
/(^|[\s("'`[])((?:\.{1,2}\/)?(?:[\w.-]+\/)*[\w.-]+\.(?:html?|md|markdown|png|jpe?g|gif|webp|svg|mp4|webm|mov|m4v))(?![\w/-])/gi
export function extractAssistantOutputTargets(
content: string,
@ -283,6 +284,10 @@ function classifyFileTarget(candidate: string): FileTargetMatch['kind'] | null {
return 'image'
}
if (['.mp4', '.webm', '.mov', '.m4v'].includes(extension)) {
return 'video'
}
return null
}
@ -388,7 +393,7 @@ function normalizeMarkdownDestination(destination: string): string {
normalized = normalized.slice(1, -1).trim()
}
const lineSuffixMatch = normalized.match(/^(.*\.(?:html?|md|markdown|png|jpe?g|gif|webp|svg)):\d+$/i)
const lineSuffixMatch = normalized.match(/^(.*\.(?:html?|md|markdown|png|jpe?g|gif|webp|svg|mp4|webm|mov|m4v)):\d+$/i)
const lineSuffixedPath = lineSuffixMatch?.[1]
if (lineSuffixedPath && !isExternalUrl(lineSuffixedPath)) {