mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
7d07a7cadc
commit
d7d7cffddb
@ -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
|
||||
|
||||
@ -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" />
|
||||
)}
|
||||
|
||||
60
desktop/src/components/chat/InlineVideoGallery.test.tsx
Normal file
60
desktop/src/components/chat/InlineVideoGallery.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
84
desktop/src/components/chat/InlineVideoGallery.tsx
Normal file
84
desktop/src/components/chat/InlineVideoGallery.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>)',
|
||||
|
||||
@ -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)) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user