feat(audit): 审计修复第四轮 — 跨会话搜索、LLM压缩集成、Presentation渲染器
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

- S9: MessageSearch 新增 Session/Global 双模式,Global 调用 VikingStorage memory_search
- M4b: LLM 压缩器集成到 kernel AgentLoop,支持 use_llm 配置切换
- M4c: 压缩时自动提取记忆到 VikingStorage (runtime + tauri 双路径)
- H6: 新增 ChartRenderer(recharts)、Document/Slideshow 完整渲染
- 累计修复 23 项,整体完成度 ~72%,真实可用率 ~80%
This commit is contained in:
iven
2026-03-27 11:44:14 +08:00
parent 7ae6990c97
commit 30b2515f07
16 changed files with 2121 additions and 245 deletions

View File

@@ -18,6 +18,7 @@ import { QuizRenderer } from './renderers/QuizRenderer';
const SlideshowRenderer = React.lazy(() => import('./renderers/SlideshowRenderer').then(m => ({ default: m.SlideshowRenderer })));
const DocumentRenderer = React.lazy(() => import('./renderers/DocumentRenderer').then(m => ({ default: m.DocumentRenderer })));
const ChartRenderer = React.lazy(() => import('./renderers/ChartRenderer').then(m => ({ default: m.ChartRenderer })));
interface PresentationContainerProps {
/** Pipeline output data */
@@ -78,7 +79,7 @@ export function PresentationContainer({
if (supportedTypes && supportedTypes.length > 0) {
return supportedTypes.filter((t): t is PresentationType => t !== 'auto');
}
return (['quiz', 'slideshow', 'document', 'whiteboard'] as PresentationType[]);
return (['quiz', 'slideshow', 'document', 'chart', 'whiteboard'] as PresentationType[]);
}, [supportedTypes]);
const renderContent = () => {
@@ -111,11 +112,21 @@ export function PresentationContainer({
case 'whiteboard':
return (
<div className="flex items-center justify-center h-64 bg-gray-50">
<p className="text-gray-500">...</p>
<div className="flex flex-col items-center justify-center h-64 bg-gray-50 gap-3">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
</span>
<p className="text-gray-500"></p>
</div>
);
case 'chart':
return (
<React.Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
<ChartRenderer data={data as Parameters<typeof ChartRenderer>[0]['data']} />
</React.Suspense>
);
default:
return (
<div className="flex items-center justify-center h-64 bg-gray-50">

View File

@@ -0,0 +1,204 @@
/**
* Chart Renderer
*
* Renders data as interactive charts using recharts.
* Supports: line, bar, pie, scatter, area chart types.
*/
import {
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
ScatterChart, Scatter, AreaChart, Area,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import type { ChartData } from '../types';
const DEFAULT_COLORS = [
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1',
];
interface ChartRendererProps {
data: ChartData;
className?: string;
}
export function ChartRenderer({ data, className = '' }: ChartRendererProps) {
const { type, title, labels, datasets, options } = data;
// Transform datasets + labels into recharts data format
const chartData = (labels || []).map((label, i) => {
const point: Record<string, string | number> = { name: label };
for (const ds of datasets) {
point[ds.label] = ds.data[i] ?? 0;
}
return point;
});
// If no labels, use index as x-axis
if (!labels || labels.length === 0) {
const maxLen = Math.max(...datasets.map(ds => ds.data.length), 0);
for (let i = 0; i < maxLen; i++) {
const point: Record<string, string | number> = { name: `${i + 1}` };
for (const ds of datasets) {
point[ds.label] = ds.data[i] ?? 0;
}
chartData.push(point);
}
}
const showLegend = options?.plugins?.legend?.display !== false;
const legendProps = showLegend
? { wrapperStyle: { paddingBottom: 8 } }
: undefined;
const chartTitle = title || options?.plugins?.title?.text;
const renderChart = () => {
switch (type) {
case 'line':
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="name" fontSize={12} />
<YAxis fontSize={12} />
<Tooltip />
{showLegend && <Legend {...legendProps} />}
{datasets.map((ds, i) => (
<Line
key={ds.label}
type="monotone"
dataKey={ds.label}
stroke={Array.isArray(ds.borderColor) ? ds.borderColor[0] : (ds.borderColor || DEFAULT_COLORS[i % DEFAULT_COLORS.length])}
strokeWidth={2}
dot={{ r: 3 }}
fill={Array.isArray(ds.backgroundColor) ? undefined : (ds.backgroundColor as string | undefined)}
/>
))}
</LineChart>
</ResponsiveContainer>
);
case 'bar':
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="name" fontSize={12} />
<YAxis fontSize={12} />
<Tooltip />
{showLegend && <Legend {...legendProps} />}
{datasets.map((ds, i) => (
<Bar
key={ds.label}
dataKey={ds.label}
fill={Array.isArray(ds.backgroundColor) ? ds.backgroundColor[0] : (ds.backgroundColor || DEFAULT_COLORS[i % DEFAULT_COLORS.length])}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
);
case 'pie': {
const pieData = datasets.flatMap((ds) =>
(labels || ds.data.map((_, i) => `${i + 1}`)).map((label, i) => ({
name: label,
value: ds.data[i] ?? 0,
}))
);
return (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
labelLine
label={({ name, percent }: { name?: string; percent?: number }) => `${name ?? ''} ${((percent ?? 0) * 100).toFixed(0)}%`}
outerRadius="70%"
dataKey="value"
>
{pieData.map((_, i) => (
<Cell key={i} fill={DEFAULT_COLORS[i % DEFAULT_COLORS.length]} />
))}
</Pie>
<Tooltip />
{showLegend && <Legend />}
</PieChart>
</ResponsiveContainer>
);
}
case 'scatter': {
const scatterData = datasets.flatMap((ds) =>
ds.data.map((val, i) => ({
x: labels ? i : i + 1,
y: val,
name: ds.label,
}))
);
return (
<ResponsiveContainer width="100%" height="100%">
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="x" name="X" fontSize={12} />
<YAxis dataKey="y" name="Y" fontSize={12} />
<Tooltip cursor={{ strokeDasharray: '3 3' }} />
{showLegend && <Legend />}
<Scatter name={datasets[0]?.label || '数据'} data={scatterData} fill={DEFAULT_COLORS[0]} />
</ScatterChart>
</ResponsiveContainer>
);
}
case 'area':
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="name" fontSize={12} />
<YAxis fontSize={12} />
<Tooltip />
{showLegend && <Legend {...legendProps} />}
{datasets.map((ds, i) => (
<Area
key={ds.label}
type="monotone"
dataKey={ds.label}
stroke={Array.isArray(ds.borderColor) ? ds.borderColor[0] : (ds.borderColor || DEFAULT_COLORS[i % DEFAULT_COLORS.length])}
fill={Array.isArray(ds.backgroundColor) ? ds.backgroundColor[0] : (ds.backgroundColor || `${DEFAULT_COLORS[i % DEFAULT_COLORS.length]}33`)}
strokeWidth={2}
/>
))}
</AreaChart>
</ResponsiveContainer>
);
default:
return <p className="text-gray-500 text-center">: {type}</p>;
}
};
return (
<div className={`flex flex-col h-full ${className}`}>
{chartTitle && (
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">{chartTitle}</h2>
</div>
)}
<div className="flex-1 p-4" style={{ minHeight: 300 }}>
{datasets.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-gray-400"></p>
</div>
) : (
renderChart()
)}
</div>
</div>
);
}
export default ChartRenderer;

View File

@@ -1,10 +1,13 @@
/**
* Document Renderer
*
* Renders content as a scrollable document with Markdown support.
* Renders content as a scrollable document with full Markdown support
* via react-markdown + remark-gfm (tables, strikethrough, task lists, etc.).
*/
import { useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Download, ExternalLink, Copy } from 'lucide-react';
import type { DocumentData } from '../types';
@@ -26,12 +29,14 @@ export function DocumentRenderer({
const handleCopy = async () => {
try {
const textToCopy = typeof data === 'string' ? data : (data.content || JSON.stringify(data, null, 2));
const textToCopy = typeof data === 'string'
? data
: (data.content || JSON.stringify(data, null, 2));
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
} catch {
// Clipboard API may not be available in all contexts
}
};
@@ -46,58 +51,14 @@ export function DocumentRenderer({
}
};
const renderMarkdown = (content: string): React.ReactNode => {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('# ')) {
elements.push(
<h1 key={trimmed} className="text-2xl font-bold mb-4">
{trimmed.substring(2)}
</h1>
);
} else if (trimmed.startsWith('## ')) {
elements.push(
<h2 key={trimmed} className="text-xl font-semibold mb-3">
{trimmed.substring(3)}
</h2>
);
} else if (trimmed.startsWith('### ')) {
elements.push(
<h3 key={trimmed} className="text-lg font-medium mb-2">
{trimmed.substring(4)}
</h3>
);
} else if (trimmed.startsWith('- ')) {
elements.push(
<li key={trimmed} className="ml-4 list-disc">
{trimmed.substring(2)}
</li>
);
} else if (trimmed.startsWith('```')) {
elements.push(
<pre key={trimmed} className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm my-2">
<code>{trimmed.substring(3, trimmed.length - 3)}</code>
</pre>
);
} else {
elements.push(
<p key={trimmed} className="mb-2">{trimmed}</p>
);
}
}
return <div className={className}>{elements}</div>;
};
const content = typeof data === 'string'
? data
: (data.content || JSON.stringify(data, null, 2));
if (!enableMarkdown) {
return (
<div className={`flex flex-col h-full ${className}`}>
<pre className="whitespace-pre-wrap text-sm">{JSON.stringify(data, null, 2)}</pre>
<pre className="whitespace-pre-wrap text-sm">{content}</pre>
</div>
);
}
@@ -138,10 +99,8 @@ export function DocumentRenderer({
</div>
)}
<div className="flex-1 overflow-auto p-6">
{typeof data === 'string'
? renderMarkdown(data)
: renderMarkdown(data.content || JSON.stringify(data))}
<div className="flex-1 overflow-auto p-6 prose prose-gray max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
</div>
</div>
);

View File

@@ -2,9 +2,12 @@
* Slideshow Renderer
*
* Renders presentation as a slideshow with slide navigation.
* Supports: title, content, image, code, twoColumn slide types.
*/
import { useState, useEffect, useCallback } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
ChevronLeft,
ChevronRight,
@@ -13,7 +16,7 @@ import {
Play,
Pause,
} from 'lucide-react';
import type { SlideshowData } from '../types';
import type { SlideshowData, Slide } from '../types';
interface SlideshowRendererProps {
data: SlideshowData;
@@ -41,30 +44,6 @@ export function SlideshowRenderer({
const slides = data.slides || [];
const totalSlides = slides.length;
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
handleNext();
} else if (e.key === 'ArrowLeft') {
handlePrev();
} else if (e.key === 'f') {
toggleFullscreen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Auto-play
useEffect(() => {
if (isPlaying && autoPlayInterval > 0) {
const timer = setInterval(handleNext, autoPlayInterval * 1000);
return () => clearInterval(timer);
}
}, [isPlaying, autoPlayInterval]);
const handleNext = useCallback(() => {
setCurrentIndex((prev) => (prev + 1) % totalSlides);
}, [totalSlides]);
@@ -77,6 +56,32 @@ export function SlideshowRenderer({
setIsFullscreen((prev) => !prev);
}, []);
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
e.preventDefault();
handleNext();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
handlePrev();
} else if (e.key === 'f') {
toggleFullscreen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleNext, handlePrev, toggleFullscreen]);
// Auto-play
useEffect(() => {
if (isPlaying && autoPlayInterval > 0) {
const timer = setInterval(handleNext, autoPlayInterval * 1000);
return () => clearInterval(timer);
}
}, [isPlaying, autoPlayInterval, handleNext]);
const currentSlide = slides[currentIndex];
if (!currentSlide) {
@@ -88,26 +93,15 @@ export function SlideshowRenderer({
}
return (
<div className={`flex flex-col h-full ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''} ${className}`}>
<div
className={`flex flex-col h-full ${
isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''
} ${className}`}
>
{/* Slide Content */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="max-w-4xl w-full">
{/* Title */}
{currentSlide.title && (
<h2 className="text-3xl font-bold text-center mb-6">
{currentSlide.title}
</h2>
)}
{/* Content rendering would go here */}
<div className="text-gray-700">
{/* This is simplified - real implementation would render based on content type */}
{typeof currentSlide.content === 'string' ? (
<p>{currentSlide.content}</p>
) : (
<div>Complex content rendering</div>
)}
</div>
<SlideContent slide={currentSlide} />
</div>
</div>
@@ -127,7 +121,11 @@ export function SlideshowRenderer({
disabled={autoPlayInterval === 0}
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5" />
)}
</button>
<button
@@ -162,11 +160,124 @@ export function SlideshowRenderer({
{/* Speaker Notes */}
{showNotes && currentSlide.notes && (
<div className="p-4 bg-yellow-50 border-t text-sm text-gray-600">
📝 {currentSlide.notes}
{currentSlide.notes}
</div>
)}
</div>
);
}
/** Renders a single slide based on its type */
function SlideContent({ slide }: { slide: Slide }) {
switch (slide.type) {
case 'title':
return (
<div className="text-center py-12">
{slide.title && (
<h1 className="text-4xl font-bold mb-4">{slide.title}</h1>
)}
{slide.content && (
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
{slide.content}
</p>
)}
</div>
);
case 'content':
return (
<div>
{slide.title && (
<h2 className="text-3xl font-bold text-center mb-6">{slide.title}</h2>
)}
{slide.content && (
<div className="prose prose-gray max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{slide.content}</Markdown>
</div>
)}
</div>
);
case 'image':
return (
<div className="text-center">
{slide.title && (
<h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
)}
{slide.image && (
<img
src={slide.image}
alt={slide.title || '幻灯片图片'}
className="max-w-full max-h-[60vh] mx-auto rounded-lg shadow-md"
/>
)}
{slide.content && (
<p className="mt-4 text-gray-600">{slide.content}</p>
)}
</div>
);
case 'code':
return (
<div>
{slide.title && (
<h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
)}
{slide.code && (
<pre className="bg-gray-900 text-gray-100 p-6 rounded-lg overflow-x-auto text-sm">
{slide.language && (
<div className="text-xs text-gray-400 mb-3 uppercase tracking-wider">
{slide.language}
</div>
)}
<code>{slide.code}</code>
</pre>
)}
{slide.content && (
<p className="mt-4 text-gray-600">{slide.content}</p>
)}
</div>
);
case 'twoColumn':
return (
<div>
{slide.title && (
<h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
)}
<div className="grid grid-cols-2 gap-8">
<div className="prose prose-sm max-w-none">
{slide.leftContent && (
<Markdown remarkPlugins={[remarkGfm]}>
{slide.leftContent}
</Markdown>
)}
</div>
<div className="prose prose-sm max-w-none">
{slide.rightContent && (
<Markdown remarkPlugins={[remarkGfm]}>
{slide.rightContent}
</Markdown>
)}
</div>
</div>
</div>
);
default:
return (
<div>
{slide.title && (
<h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
)}
{slide.content && (
<div className="prose prose-gray max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{slide.content}</Markdown>
</div>
)}
</div>
);
}
}
export default SlideshowRenderer;