From 2d1227c3e20e3713db03f445bae73c36538cf58c Mon Sep 17 00:00:00 2001 From: Lriver Date: Thu, 9 Jul 2026 17:52:40 +0800 Subject: [PATCH] feat(studio): add artifact detail workspace --- frontend/e2e/interactive-artifacts.spec.ts | 408 ++++++++++++++- frontend/src/App.tsx | 6 +- frontend/src/components/ArtifactViewer.tsx | 177 ++++++- frontend/src/components/StudioPanel.tsx | 143 +++-- .../components/markdown-rendering.test.tsx | 145 ++++- frontend/src/index.css | 495 ++++++++++++++++-- 6 files changed, 1255 insertions(+), 119 deletions(-) diff --git a/frontend/e2e/interactive-artifacts.spec.ts b/frontend/e2e/interactive-artifacts.spec.ts index 2dd1c2c..a35e7e8 100644 --- a/frontend/e2e/interactive-artifacts.spec.ts +++ b/frontend/e2e/interactive-artifacts.spec.ts @@ -56,12 +56,34 @@ const artifactPayloads: Record> = { }, data_table: { title: '协议对比表', - columns: ['协议', '安全性', '用途'], + columns: ['协议', '安全性', '用途', '证书配置', '兼容性', '运维关注', '典型配置与注意事项'], rows: [ - { 协议: 'HTTP', 安全性: '明文', 用途: '普通网页' }, - { 协议: 'HTTPS', 安全性: 'TLS 加密', 用途: '登录和支付' } + { + 协议: 'HTTP', + 安全性: '明文', + 用途: '普通网页', + 证书配置: '不需要证书', + 兼容性: '所有浏览器支持', + 运维关注: '避免承载敏感业务', + 典型配置与注意事项: '只适合公开内容,不应用于登录、支付、私密 API 或需要完整性保护的业务链路。' + }, + { + 协议: 'HTTPS', + 安全性: 'TLS 加密', + 用途: '登录和支付', + 证书配置: '需要可信证书链', + 兼容性: '现代浏览器默认优先', + 运维关注: '续期、TLS 策略、混合内容', + 典型配置与注意事项: '需要可信证书链、现代 TLS 配置、证书续期监控,并避免混合内容。' + } ] }, + video_overview: { + title: '视频概览占位', + adapter_status: 'placeholder', + official_capability: 'Video Overview', + message: '功能开发中,后续会接入视频概览生成。' + }, infographic: { title: 'HTTPS 信息图', subtitle: '基于来源生成的视觉摘要', @@ -74,6 +96,12 @@ const artifactPayloads: Record> = { } }; +const luminance = (cssColor: string) => { + const colors = [...cssColor.matchAll(/rgba?\((\d+),\s*(\d+),\s*(\d+)/g)]; + if (!colors.length) return 255; + return Math.max(...colors.map(([, red, green, blue]) => 0.2126 * Number(red) + 0.7152 * Number(green) + 0.0722 * Number(blue))); +}; + test('renders NotebookLM-like interactive Studio artifacts', async ({ page }) => { let sources: typeof source[] = []; let flashcardsInstruction = ''; @@ -191,6 +219,7 @@ test('renders NotebookLM-like interactive Studio artifacts', async ({ page }) => await expect(page.getByText('HTTPS 在 HTTP 之下使用 TLS 建立安全通道。')).toBeVisible(); await page.getByRole('button', { name: '重做' }).click(); await expect(page.getByText('得分 1 / 1')).not.toBeVisible(); + await page.getByRole('button', { name: /工作室/ }).click(); await page.getByRole('button', { name: /^PPT$/ }).click(); await expect(page.getByText('Slide Deck 工作区')).toBeVisible(); @@ -200,10 +229,38 @@ test('renders NotebookLM-like interactive Studio artifacts', async ({ page }) => await page.getByRole('button', { name: /思维图谱/ }).click(); await page.getByRole('button', { name: '生成', exact: true }).click(); await page.getByRole('button', { name: /HTTPS 思维图谱/ }).click(); + await expect.poll(async () => page.locator('.panel-right').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBeGreaterThan(500); await expect(page.getByRole('button', { name: 'HTTPS 2' })).toBeVisible(); await expect(page.getByText('TLS 加密')).toBeVisible(); - await page.getByRole('button', { name: /TLS 加密/ }).click(); await expect(page.getByText('握手协商')).toBeVisible(); + await expect(page.locator('.mind-map-viewer button').first()).toHaveAccessibleName('HTTPS 2'); + await expect(page.locator('.mind-map-viewer')).not.toHaveAttribute('tabindex', '0'); + const rootBox = await page.getByRole('button', { name: 'HTTPS 2' }).boundingBox(); + const childBox = await page.getByRole('button', { name: /TLS 加密/ }).boundingBox(); + expect(rootBox).not.toBeNull(); + expect(childBox).not.toBeNull(); + expect(childBox!.x).toBeGreaterThan(rootBox!.x + rootBox!.width); + await expect.poll(async () => page.locator('.mind-map-link').count()).toBeGreaterThanOrEqual(2); + await expect.poll(async () => page.locator('.mind-map-link').first().evaluate(element => { + const style = window.getComputedStyle(element); + return { + stroke: style.stroke, + strokeWidth: Number.parseFloat(style.strokeWidth), + opacity: Number.parseFloat(style.opacity || '1') + }; + })).toMatchObject({ strokeWidth: expect.any(Number), opacity: expect.any(Number) }); + const firstLinkStyle = await page.locator('.mind-map-link').first().evaluate(element => { + const style = window.getComputedStyle(element); + return { + stroke: style.stroke, + strokeWidth: Number.parseFloat(style.strokeWidth), + opacity: Number.parseFloat(style.opacity || '1') + }; + }); + expect(firstLinkStyle.stroke).not.toBe('none'); + expect(firstLinkStyle.strokeWidth).toBeGreaterThanOrEqual(2); + expect(firstLinkStyle.opacity).toBeGreaterThanOrEqual(0.8); + await page.getByRole('button', { name: /工作室/ }).click(); await page.getByRole('button', { name: /报告/ }).click(); await page.getByRole('button', { name: '生成', exact: true }).click(); @@ -211,6 +268,7 @@ test('renders NotebookLM-like interactive Studio artifacts', async ({ page }) => await expect(page.getByText('HTTPS 通过 TLS 保护 HTTP 通信。')).toBeVisible(); await expect(page.getByRole('heading', { name: '安全属性' })).toBeVisible(); await expect(page.getByText('证书链需要可信')).toBeVisible(); + await page.getByRole('button', { name: /工作室/ }).click(); await page.getByRole('button', { name: /^FAQ$/ }).click(); await page.getByRole('button', { name: '生成', exact: true }).click(); @@ -219,6 +277,7 @@ test('renders NotebookLM-like interactive Studio artifacts', async ({ page }) => await expect(page.getByText('它通过 TLS 降低窃听、篡改和身份冒充风险。')).not.toBeVisible(); await page.getByRole('button', { name: 'HTTPS 解决什么问题?' }).click(); await expect(page.getByText('它通过 TLS 降低窃听、篡改和身份冒充风险。')).toBeVisible(); + await page.getByRole('button', { name: /工作室/ }).click(); await page.getByRole('button', { name: /表格/ }).click(); await page.getByRole('button', { name: '生成', exact: true }).click(); @@ -228,6 +287,14 @@ test('renders NotebookLM-like interactive Studio artifacts', async ({ page }) => const tableRegion = page.getByRole('region', { name: '协议对比表' }); await expect(tableRegion).toBeVisible(); await expect.poll(async () => tableRegion.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true); + await tableRegion.evaluate(element => { element.scrollLeft = element.scrollWidth; }); + await expect.poll(async () => tableRegion.evaluate(element => element.scrollLeft)).toBeGreaterThan(0); + const regionBox = await tableRegion.boundingBox(); + const lastCellBox = await page.getByRole('cell', { name: /需要可信证书链、现代 TLS 配置/ }).boundingBox(); + expect(regionBox).not.toBeNull(); + expect(lastCellBox).not.toBeNull(); + expect(lastCellBox!.x + lastCellBox!.width).toBeLessThanOrEqual(regionBox!.x + regionBox!.width + 1); + await page.getByRole('button', { name: /工作室/ }).click(); let developmentMessage = ''; page.once('dialog', async dialog => { @@ -245,3 +312,336 @@ test('renders NotebookLM-like interactive Studio artifacts', async ({ page }) => await expect(page.locator('.infographic-frame img')).toBeVisible(); await expect(page.getByRole('link', { name: /SVG/ })).toBeVisible(); }); + +test('keeps artifact detail full-width in narrow stacked layouts', async ({ page }) => { + await page.setViewportSize({ width: 760, height: 900 }); + await page.route('**/api/config', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ models: {} }) + })); + await page.route('**/api/sources', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sources: [source], total: 1 }) + })); + await page.route('**/api/notes', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: [], total: 0 }) + })); + await page.route('**/api/artifacts', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + artifacts: [{ + id: 'artifact-mind_map', + artifact_type: 'mind_map', + title: artifactPayloads.mind_map.title, + created_at: '2026-06-01T00:00:00Z', + source_ids: ['src-material'], + markdown: '# HTTPS 思维图谱', + payload: artifactPayloads.mind_map + }], + total: 1 + }) + })); + + await page.goto('/'); + await page.getByRole('button', { name: /HTTPS 思维图谱/ }).click(); + + await expect.poll(async () => page.locator('.panel-right').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBeGreaterThan(680); +}); + +test('preserves usable chat width when artifact details are open on desktop', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 860 }); + await page.route('**/api/config', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ models: {} }) + })); + await page.route('**/api/sources', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sources: [source], total: 1 }) + })); + await page.route('**/api/notes', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: [], total: 0 }) + })); + await page.route('**/api/artifacts', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + artifacts: [{ + id: 'artifact-mind_map', + artifact_type: 'mind_map', + title: artifactPayloads.mind_map.title, + created_at: '2026-06-01T00:00:00Z', + source_ids: ['src-material'], + markdown: '# HTTPS 思维图谱', + payload: artifactPayloads.mind_map + }], + total: 1 + }) + })); + + await page.goto('/'); + await page.getByRole('button', { name: /HTTPS 思维图谱/ }).click(); + + await expect.poll(async () => page.locator('.panel-center').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBeGreaterThan(300); +}); + +test('keeps artifact detail layout usable on phone widths', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 860 }); + await page.route('**/api/config', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ models: {} }) + })); + await page.route('**/api/sources', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sources: [source], total: 1 }) + })); + await page.route('**/api/notes', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: [], total: 0 }) + })); + await page.route('**/api/artifacts', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + artifacts: [{ + id: 'artifact-mind_map', + artifact_type: 'mind_map', + title: artifactPayloads.mind_map.title, + created_at: '2026-06-01T00:00:00Z', + source_ids: ['src-material'], + markdown: '# HTTPS 思维图谱', + payload: artifactPayloads.mind_map + }], + total: 1 + }) + })); + + await page.goto('/'); + await page.getByRole('button', { name: /HTTPS 思维图谱/ }).click(); + + await expect.poll(async () => page.locator('.panel-center').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBeGreaterThan(300); + await expect.poll(async () => page.locator('.panel-right').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBeGreaterThan(300); +}); + +test('opens persisted slide deck artifacts in the Slide Deck workspace', async ({ page }) => { + let requestedDeckId = ''; + await page.route('**/api/config', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ models: {} }) + })); + await page.route('**/api/sources', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sources: [source], total: 1 }) + })); + await page.route('**/api/notes', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: [], total: 0 }) + })); + await page.route('**/api/artifacts', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + artifacts: [{ + id: 'artifact-slide-deck', + artifact_type: 'slide_deck', + title: '恢复的演示文稿', + created_at: '2026-06-01T00:00:00Z', + source_ids: ['src-material'], + markdown: '# 恢复的演示文稿', + payload: { deck_id: 'deck-restored-1' } + }], + total: 1 + }) + })); + await page.route('**/api/slide-decks/deck-restored-1', route => { + requestedDeckId = 'deck-restored-1'; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'deck-restored-1', + title: '恢复的演示文稿', + source_ids: ['src-material'], + source_snapshot: [{ source_id: 'src-material', title: 'learning.md', excerpt: 'TLS protects HTTP.' }], + config_snapshot: { aspect_ratio: '16:9', page_count: 6 }, + outline: null, + prompt_plan: null, + slides: [], + status: 'draft', + stage: 'created', + error: null, + created_at: '2026-06-01T00:00:00Z', + updated_at: '2026-06-01T00:00:00Z' + }) + }); + }); + + await page.goto('/'); + await page.getByRole('button', { name: /恢复的演示文稿/ }).click(); + + await expect(page.getByText('Slide Deck 工作区')).toBeVisible(); + await expect(page.getByText('恢复的演示文稿')).toBeVisible(); + await expect(page.getByRole('button', { name: /生成大纲/ })).toBeVisible(); + expect(requestedDeckId).toBe('deck-restored-1'); + + await page.getByRole('button', { name: /返回/ }).click(); + await expect(page.getByRole('button', { name: /恢复的演示文稿/ })).toBeVisible(); +}); + +test('keeps Studio artifact details readable and visually integrated in dark mode', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('notebooklm-theme', 'dark')); + await page.route('**/api/config', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ models: {} }) + })); + await page.route('**/api/sources', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sources: [source], total: 1 }) + })); + await page.route('**/api/notes', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: [], total: 0 }) + })); + await page.route('**/api/artifacts', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + artifacts: [{ + id: 'artifact-mind_map', + artifact_type: 'mind_map', + title: artifactPayloads.mind_map.title, + created_at: '2026-06-01T00:00:00Z', + source_ids: ['src-material'], + markdown: '# HTTPS 思维图谱', + payload: artifactPayloads.mind_map + }], + total: 1 + }) + })); + + await page.goto('/'); + await page.getByRole('button', { name: /HTTPS 思维图谱/ }).click(); + + const styles = await page.evaluate(() => { + const styleOf = (selector: string) => window.getComputedStyle(document.querySelector(selector)!); + return { + backColor: styleOf('.studio-back-btn').color, + titleColor: styleOf('.artifact-detail-name').color, + mindMapBackground: styleOf('.mind-map-viewer').backgroundImage, + mindNodeBackground: styleOf('.mind-graph-node').backgroundColor + }; + }); + + expect(luminance(styles.backColor)).toBeGreaterThan(170); + expect(luminance(styles.titleColor)).toBeGreaterThan(170); + expect(luminance(styles.mindMapBackground)).toBeLessThan(120); + expect(luminance(styles.mindNodeBackground)).toBeLessThan(160); +}); + +test('keeps non-mind-map Studio artifact detail surfaces integrated in dark mode', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('notebooklm-theme', 'dark')); + await page.route('**/api/config', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ models: {} }) + })); + await page.route('**/api/sources', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sources: [source], total: 1 }) + })); + await page.route('**/api/notes', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notes: [], total: 0 }) + })); + await page.route('**/api/artifacts', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + artifacts: ['flashcards', 'report', 'faq', 'data_table', 'infographic', 'video_overview'].map(type => ({ + id: `artifact-${type}`, + artifact_type: type, + title: artifactPayloads[type].title, + created_at: '2026-06-01T00:00:00Z', + source_ids: ['src-material'], + markdown: `# ${artifactPayloads[type].title}`, + payload: artifactPayloads[type] + })), + total: 6 + }) + })); + + await page.goto('/'); + + await page.getByRole('button', { name: /HTTPS 学习卡/ }).click(); + const flashcardStyles = await page.evaluate(() => { + const flashcard = window.getComputedStyle(document.querySelector('.flashcard')!); + const quizPanel = window.getComputedStyle(document.querySelector('.quiz-panel')!); + return { flashcardBackground: flashcard.backgroundImage, quizBackground: quizPanel.backgroundColor }; + }); + expect(luminance(flashcardStyles.flashcardBackground)).toBeLessThan(150); + expect(luminance(flashcardStyles.quizBackground)).toBeLessThan(150); + await page.getByRole('button', { name: /工作室/ }).click(); + + await page.getByRole('button', { name: /HTTPS 报告/ }).click(); + const reportStyles = await page.evaluate(() => { + const summary = window.getComputedStyle(document.querySelector('.report-summary')!); + const section = window.getComputedStyle(document.querySelector('.report-section')!); + return { summaryBackground: summary.backgroundColor, sectionBackground: section.backgroundColor }; + }); + expect(luminance(reportStyles.summaryBackground)).toBeLessThan(150); + expect(luminance(reportStyles.sectionBackground)).toBeLessThan(150); + await page.getByRole('button', { name: /工作室/ }).click(); + + await page.getByRole('button', { name: /HTTPS FAQ/ }).click(); + const faqStyles = await page.evaluate(() => window.getComputedStyle(document.querySelector('.faq-item')!).backgroundColor); + expect(luminance(faqStyles)).toBeLessThan(150); + await page.getByRole('button', { name: /工作室/ }).click(); + + await page.getByRole('button', { name: /协议对比表/ }).click(); + const tableStyles = await page.evaluate(() => { + const table = window.getComputedStyle(document.querySelector('.data-table-viewer')!); + const header = window.getComputedStyle(document.querySelector('.data-table-viewer th')!); + return { tableBackground: table.backgroundColor, headerBackground: header.backgroundColor }; + }); + expect(luminance(tableStyles.tableBackground)).toBeLessThan(150); + expect(luminance(tableStyles.headerBackground)).toBeLessThan(150); + await page.getByRole('button', { name: /工作室/ }).click(); + + await page.getByRole('button', { name: /HTTPS 信息图/ }).click(); + const infographicStyles = await page.evaluate(() => { + const frame = window.getComputedStyle(document.querySelector('.infographic-frame')!); + const section = window.getComputedStyle(document.querySelector('.infographic-section-item')!); + return { frameBackground: frame.backgroundColor, sectionBackground: section.backgroundColor }; + }); + expect(luminance(infographicStyles.frameBackground)).toBeLessThan(150); + expect(luminance(infographicStyles.sectionBackground)).toBeLessThan(150); + await page.getByRole('button', { name: /工作室/ }).click(); + + await page.getByRole('button', { name: /视频概览占位/ }).click(); + const placeholderStyles = await page.evaluate(() => { + const placeholder = window.getComputedStyle(document.querySelector('.placeholder-artifact')!); + const label = window.getComputedStyle(document.querySelector('.placeholder-label')!); + return { placeholderBackground: placeholder.backgroundImage, labelBackground: label.backgroundColor }; + }); + expect(luminance(placeholderStyles.placeholderBackground)).toBeLessThan(150); + expect(luminance(placeholderStyles.labelBackground)).toBeLessThan(150); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f6c6e6..8fa88a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -129,6 +129,7 @@ export interface GeneratedContent { type: string; title: string; createdAt: Date; + sourceIds?: string[]; audioUrl?: string | null; transcriptUrl?: string | null; audioFilename?: string | null; @@ -149,6 +150,7 @@ function App() { const [generatedContents, setGeneratedContents] = useState([]); const [notesRefreshKey, setNotesRefreshKey] = useState(0); const [showConfig, setShowConfig] = useState(false); + const [isStudioDetailMode, setIsStudioDetailMode] = useState(false); const [slideDeckWorkspaceId, setSlideDeckWorkspaceId] = useState(() => { return localStorage.getItem('notebooklm-active-slide-deck') || undefined; }); @@ -324,6 +326,7 @@ function App() { type: artifact.artifact_type, title: artifact.title, createdAt: new Date(artifact.created_at), + sourceIds: artifact.source_ids || [], markdown: artifact.markdown, payload: artifact.payload, fileRefs: artifact.file_refs || [], @@ -398,7 +401,7 @@ function App() { onDeckReady={rememberSlideDeck} /> ) : ( -
+
diff --git a/frontend/src/components/ArtifactViewer.tsx b/frontend/src/components/ArtifactViewer.tsx index 4a0de08..9268eb8 100644 --- a/frontend/src/components/ArtifactViewer.tsx +++ b/frontend/src/components/ArtifactViewer.tsx @@ -4,6 +4,21 @@ import { useLanguage, type GeneratedContent } from '../App'; import { MarkdownView } from './MarkdownView'; type AnyRecord = Record; +type MindLayoutNode = { + id: string; + label: string; + childCount: number; + depth: number; + x: number; + y: number; + width: number; + height: number; +}; + +type MindLayoutLink = { + from: MindLayoutNode; + to: MindLayoutNode; +}; interface ArtifactViewerProps { content: GeneratedContent; @@ -11,6 +26,76 @@ interface ArtifactViewerProps { const textValue = (value: unknown) => value === undefined || value === null ? '' : String(value); +const mindNodeId = (node: AnyRecord, path: string) => `${path}:${textValue(node.id || node.label || 'node')}`; + +const collectExpandableMindIds = (node: AnyRecord, path = 'root'): string[] => { + const children = Array.isArray(node.children) ? node.children : []; + const id = mindNodeId(node, path); + return [ + ...(children.length ? [id] : []), + ...children.flatMap((child: AnyRecord, index: number) => collectExpandableMindIds(child, `${path}-${index}`)) + ]; +}; + +const mindNodeWidth = (label: string, depth: number) => { + const base = depth === 0 ? 165 : depth === 1 ? 160 : 130; + const max = depth === 0 ? 220 : 200; + return Math.max(base, Math.min(max, label.length * 7 + 46)); +}; + +const layoutMindMap = (root: AnyRecord, expandedIds: Set) => { + const nodes: MindLayoutNode[] = []; + const links: MindLayoutLink[] = []; + let nextLeafY = 28; + + const visit = (node: AnyRecord, depth: number, path: string): MindLayoutNode => { + const children = Array.isArray(node.children) ? node.children : []; + const id = mindNodeId(node, path); + const label = textValue(node.label || node.title || 'Untitled'); + const width = mindNodeWidth(label, depth); + const height = 42; + const visibleChildren = expandedIds.has(id) + ? children.map((child: AnyRecord, index: number) => visit(child, depth + 1, `${path}-${index}`)) + : []; + const y = visibleChildren.length + ? visibleChildren.reduce((total, child) => total + child.y, 0) / visibleChildren.length + : nextLeafY; + if (!visibleChildren.length) { + nextLeafY += height + 28; + } + const layoutNode = { + id, + label, + childCount: children.length, + depth, + x: 28, + y, + width, + height + }; + nodes.push(layoutNode); + visibleChildren.forEach(child => links.push({ from: layoutNode, to: child })); + return layoutNode; + }; + + visit(root, 0, 'root'); + const columnWidths = nodes.reduce((widths, node) => { + widths[node.depth] = Math.max(widths[node.depth] || 0, node.width); + return widths; + }, []); + const columnGap = 18; + const columnX = columnWidths.reduce((positions, width, depth) => { + positions[depth] = depth === 0 ? 28 : positions[depth - 1] + columnWidths[depth - 1] + columnGap; + return positions; + }, []); + nodes.forEach(node => { + node.x = columnX[node.depth] || 28; + }); + const width = Math.max(620, ...nodes.map(node => node.x + node.width + 48)); + const height = Math.max(360, ...nodes.map(node => node.y + node.height + 36)); + return { nodes, links, width, height }; +}; + const FAQViewer: React.FC<{ payload: AnyRecord; markdown?: string }> = ({ payload, markdown }) => { const items = Array.isArray(payload.items) ? payload.items : []; const [openIndex, setOpenIndex] = useState(null); @@ -174,32 +259,80 @@ const InfographicViewer: React.FC<{ payload: AnyRecord; markdown?: string }> = ( ); }; -const MindMapNode: React.FC<{ node: AnyRecord; depth?: number }> = ({ node, depth = 0 }) => { - const children = Array.isArray(node.children) ? node.children : []; - const [expanded, setExpanded] = useState(depth === 0); - return ( -
- - {expanded && children.length > 0 && ( -
- {children.map((child: AnyRecord, index: number) => )} -
- )} -
- ); -}; - const MindMapViewer: React.FC<{ payload: AnyRecord; markdown?: string }> = ({ payload, markdown }) => { - if (!payload.root) { + const root = payload.root; + const expandableIds = useMemo(() => root ? collectExpandableMindIds(root) : [], [root]); + const [expandedIds, setExpandedIds] = useState>(() => new Set(expandableIds)); + useEffect(() => { + setExpandedIds(new Set(expandableIds)); + }, [expandableIds]); + const layout = useMemo(() => root ? layoutMindMap(root, expandedIds) : null, [root, expandedIds]); + const toggleNode = (node: MindLayoutNode) => { + if (!node.childCount) return; + setExpandedIds(current => { + const next = new Set(current); + if (next.has(node.id)) next.delete(node.id); + else next.add(node.id); + return next; + }); + }; + if (!root || !layout) { return ; } + const renderNodes = [...layout.nodes].sort((left, right) => left.depth - right.depth || left.y - right.y || left.x - right.x); + return ( -
- +
+
+ + {renderNodes.map(node => { + const nodeClassName = `mind-graph-node depth-${Math.min(node.depth, 4)} ${node.childCount ? 'has-children' : 'leaf'}`; + const nodeStyle = { left: node.x, top: node.y, width: node.width, minHeight: node.height }; + const nodeContent = ( + <> + {node.label} + {node.childCount > 0 && ( + {expandedIds.has(node.id) ? '−' : '+'}{node.childCount} + )} + + ); + if (!node.childCount) { + return ( +
+ {nodeContent} +
+ ); + } + return ( + + ); + })} +
); }; diff --git a/frontend/src/components/StudioPanel.tsx b/frontend/src/components/StudioPanel.tsx index 28d8240..267ec1c 100644 --- a/frontend/src/components/StudioPanel.tsx +++ b/frontend/src/components/StudioPanel.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { BarChart3, ChevronDown, ChevronUp, Download, FileQuestion, Film, Headphones, Image, Loader2, Network, Presentation, Rows3, SquareStack } from 'lucide-react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { ArrowLeft, BarChart3, Download, FileQuestion, Film, Headphones, Image, Loader2, MoreVertical, Network, Presentation, Rows3, SquareStack } from 'lucide-react'; import type { ApiConfig, GeneratedContent } from '../App'; import { translations, useLanguage } from '../App'; import { ArtifactViewer } from './ArtifactViewer'; @@ -10,6 +10,7 @@ interface StudioPanelProps { contents: GeneratedContent[]; onContentGenerated: (content: GeneratedContent) => void; onOpenSlideDeck: (deckId: string | null) => void; + onDetailModeChange?: (active: boolean) => void; } const TOOLS = [ @@ -24,7 +25,27 @@ const TOOLS = [ { id: 'infographic', icon: Image, labelKey: 'infographic', styleClass: 'tool-infographic' } ]; -export const StudioPanel: React.FC = ({ sourceIds, config, contents, onContentGenerated, onOpenSlideDeck }) => { +const TYPE_LABELS: Record = { + podcast_script: { zh: '播客', en: 'Podcast' }, + mind_map: { zh: '思维导图', en: 'Mind Map' }, + faq: { zh: 'FAQ', en: 'FAQ' }, + flashcards: { zh: '卡片', en: 'Flashcards' }, + quiz: { zh: '测验', en: 'Quiz' }, + report: { zh: '报告', en: 'Report' }, + study_guide: { zh: '学习指南', en: 'Study Guide' }, + data_table: { zh: '数据表', en: 'Data Table' }, + infographic: { zh: '信息图', en: 'Infographic' }, + video_overview: { zh: '视频概览', en: 'Video Overview' }, + ppt_outline: { zh: 'PPT', en: 'PPT' }, + slide_deck: { zh: 'PPT', en: 'Slide Deck' } +}; + +const toolForType = (type: string) => { + if (type === 'podcast_script') return TOOLS[0]; + return TOOLS.find(tool => tool.id === type) || TOOLS.find(tool => tool.id === 'report')!; +}; + +export const StudioPanel: React.FC = ({ sourceIds, config, contents, onContentGenerated, onOpenSlideDeck, onDetailModeChange }) => { const { lang } = useLanguage(); const t = translations[lang]; const [activeTool, setActiveTool] = useState('podcast'); @@ -36,7 +57,15 @@ export const StudioPanel: React.FC = ({ sourceIds, config, con const [detail, setDetail] = useState('standard'); const [customPrompt, setCustomPrompt] = useState(''); const [isGenerating, setIsGenerating] = useState(false); - const [expandedId, setExpandedId] = useState(null); + const [selectedContentId, setSelectedContentId] = useState(null); + const selectedContent = useMemo( + () => contents.find(content => content.id === selectedContentId) || null, + [contents, selectedContentId] + ); + useEffect(() => { + onDetailModeChange?.(Boolean(selectedContent)); + return () => onDetailModeChange?.(false); + }, [onDetailModeChange, selectedContent]); const selectTool = (toolId: string) => { if (toolId === 'ppt_outline') { @@ -78,6 +107,7 @@ export const StudioPanel: React.FC = ({ sourceIds, config, con type: 'podcast_script', title: data.title || (lang === 'zh' ? '播客脚本' : 'Podcast Script'), createdAt: new Date(), + sourceIds, audioUrl: data.audio_url, transcriptUrl: data.transcript_url, audioFilename: data.audio_filename, @@ -129,6 +159,7 @@ export const StudioPanel: React.FC = ({ sourceIds, config, con type: artifact.artifact_type, title: artifact.title, createdAt: new Date(artifact.created_at), + sourceIds: artifact.source_ids || sourceIds, markdown: artifact.markdown, payload: artifact.payload, downloadJsonUrl: `/api/artifacts/${artifact.id}/download?format=json`, @@ -162,6 +193,49 @@ export const StudioPanel: React.FC = ({ sourceIds, config, con ].filter(Boolean).join('\n'); }; + const contentSourceCount = (content: GeneratedContent) => content.sourceIds === undefined ? sourceIds.length : content.sourceIds.length; + const typeLabel = (type: string) => TYPE_LABELS[type]?.[lang] || type; + const renderDownloads = (content: GeneratedContent) => ( +
+ {content.downloadMarkdownUrl && Markdown} + {content.downloadJsonUrl && JSON} + {content.downloadSvgUrl && SVG} + {content.audioUrl && MP3} + {content.transcriptUrl && {lang === 'zh' ? '转录文本' : 'Transcript'}} +
+ ); + + if (selectedContent) { + return ( + <> +
+ + {typeLabel(selectedContent.type)} +
+
+
+
+

{typeLabel(selectedContent.type)}

+
{selectedContent.title}
+ + {contentSourceCount(selectedContent)} {lang === 'zh' ? '个来源' : contentSourceCount(selectedContent) === 1 ? 'source' : 'sources'} + +
+ +
+ {selectedContent.audioUrl &&
+ + ); + } + return ( <>
@@ -247,39 +321,34 @@ export const StudioPanel: React.FC = ({ sourceIds, config, con

{t.noContent}

)} - {contents.map(content => ( -
- - {expandedId === content.id && ( -
- {content.audioUrl &&
- )} -
- ))} +
+ {contents.map(content => { + const tool = toolForType(content.type); + const Icon = tool.icon; + return ( + + ); + })} +
); diff --git a/frontend/src/components/markdown-rendering.test.tsx b/frontend/src/components/markdown-rendering.test.tsx index 79aeed4..a07d7f7 100644 --- a/frontend/src/components/markdown-rendering.test.tsx +++ b/frontend/src/components/markdown-rendering.test.tsx @@ -288,6 +288,7 @@ describe('Markdown rendering', () => { type: 'report', title: 'Generated Artifact', createdAt: new Date('2026-06-01T00:00:00Z'), + sourceIds: [], markdown: '## Rendered Report\n\n| 项目 | 说明 |\n| --- | --- |\n| HTTP | 明文传输 |', downloadMarkdownUrl: '/download.md', downloadJsonUrl: '/download.json' @@ -304,11 +305,20 @@ describe('Markdown rendering', () => { /> ); - fireEvent.click(screen.getByRole('button', { name: 'Generated Artifact report' })); + expect(screen.getByRole('button', { name: /Generated Artifact/ })).toHaveTextContent('0 个来源 · 报告'); + expect(screen.queryByRole('heading', { name: 'Rendered Report' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Generated Artifact/ })); + + expect(screen.queryByRole('button', { name: '更多' })).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Rendered Report' })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: 'HTTP' })).toBeInTheDocument(); expect(screen.queryByText('## Rendered Report')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '工作室' })); + + expect(screen.getByRole('button', { name: /Generated Artifact/ })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Rendered Report' })).not.toBeInTheDocument(); }); test('renders restored podcast artifact audio and transcript download links', () => { @@ -349,13 +359,144 @@ describe('Markdown rendering', () => { /> ); - fireEvent.click(screen.getByRole('button', { name: '播客脚本 podcast_script' })); + fireEvent.click(screen.getByRole('button', { name: /播客脚本/ })); expect(screen.getByRole('link', { name: /MP3/ })).toHaveAttribute('href', '/api/podcast/download/demo.mp3'); expect(screen.getByRole('link', { name: /转录文本/ })).toHaveAttribute('href', '/api/podcast/download/demo.md'); expect(screen.getByRole('link', { name: /Markdown/ })).toHaveAttribute('href', '/api/artifacts/podcast-1/download?format=markdown'); }); + test('opens restored slide deck artifacts from the generated content list', () => { + const onOpenSlideDeck = vi.fn(); + const contents: GeneratedContent[] = [ + { + id: 'deck-artifact-1', + type: 'slide_deck', + title: '产品发布演示', + createdAt: new Date('2026-06-01T00:00:00Z'), + markdown: '# 产品发布演示', + payload: { deck_id: 'deck-restored-1' } + } + ]; + + renderZh( + + ); + + fireEvent.click(screen.getByRole('button', { name: /产品发布演示/ })); + + expect(onOpenSlideDeck).toHaveBeenCalledWith('deck-restored-1'); + expect(screen.queryByRole('heading', { name: '产品发布演示' })).not.toBeInTheDocument(); + }); + + test('renders mind maps as a connected graph with collapsible multi-level nodes', () => { + const { container } = renderZh( + + ); + + expect(screen.getByRole('region', { name: 'HTTPS 思维图谱' })).toBeInTheDocument(); + expect(container.querySelectorAll('.mind-map-link')).toHaveLength(3); + expect(screen.getByRole('button', { name: /HTTPS/ })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('握手协商')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /TLS 加密/ })); + + expect(screen.queryByText('握手协商')).not.toBeInTheDocument(); + }); + + test('does not expose leaf mind map nodes as no-op buttons', () => { + renderZh( + + ); + + expect(screen.getByText('Leaf concept')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Leaf concept' })).not.toBeInTheDocument(); + }); + + test('keeps mind map expansion independent when model returns duplicate node ids', () => { + renderZh( + + ); + + const duplicateNodes = screen.getAllByRole('button', { name: 'Shared 1' }); + expect(screen.getByText('First leaf')).toBeInTheDocument(); + expect(screen.getByText('Second leaf')).toBeInTheDocument(); + + fireEvent.click(duplicateNodes[0]); + + expect(screen.queryByText('First leaf')).not.toBeInTheDocument(); + expect(screen.getByText('Second leaf')).toBeInTheDocument(); + }); + test('renders data table artifacts in an accessible horizontal scroll region', () => { renderZh(