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
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:
@@ -1,19 +1,26 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Search, X, ChevronUp, ChevronDown, Clock, User, Filter } from 'lucide-react';
|
||||
import { Search, X, ChevronUp, ChevronDown, Clock, User, Filter, Globe, MessageSquare } from 'lucide-react';
|
||||
import { Button } from './ui';
|
||||
import { useChatStore, Message } from '../store/chatStore';
|
||||
import { intelligence, PersistentMemory } from '../lib/intelligence-backend';
|
||||
|
||||
export interface SearchFilters {
|
||||
sender: 'all' | 'user' | 'assistant';
|
||||
timeRange: 'all' | 'today' | 'week' | 'month';
|
||||
}
|
||||
|
||||
export type SearchScope = 'session' | 'global';
|
||||
|
||||
export interface SearchResult {
|
||||
message: Message;
|
||||
matchIndices: Array<{ start: number; end: number }>;
|
||||
}
|
||||
|
||||
export interface GlobalSearchResult {
|
||||
memory: PersistentMemory;
|
||||
}
|
||||
|
||||
interface MessageSearchProps {
|
||||
onNavigateToMessage: (messageId: string) => void;
|
||||
}
|
||||
@@ -26,6 +33,7 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [scope, setScope] = useState<SearchScope>('session');
|
||||
const [filters, setFilters] = useState<SearchFilters>({
|
||||
sender: 'all',
|
||||
timeRange: 'all',
|
||||
@@ -33,6 +41,8 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
const [globalResults, setGlobalResults] = useState<GlobalSearchResult[]>([]);
|
||||
const [globalLoading, setGlobalLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load search history from localStorage
|
||||
@@ -63,6 +73,41 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Global search: query VikingStorage when scope is 'global'
|
||||
useEffect(() => {
|
||||
if (scope !== 'global' || !query.trim()) {
|
||||
setGlobalResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const debounceTimer = setTimeout(async () => {
|
||||
setGlobalLoading(true);
|
||||
try {
|
||||
const results = await intelligence.memory.search({
|
||||
query: query.trim(),
|
||||
limit: 20,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setGlobalResults(results.map((memory) => ({ memory })));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setGlobalResults([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setGlobalLoading(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounceTimer);
|
||||
};
|
||||
}, [scope, query]);
|
||||
|
||||
// Filter messages by time range
|
||||
const filterByTimeRange = useCallback((message: Message, timeRange: SearchFilters['timeRange']): boolean => {
|
||||
if (timeRange === 'all') return true;
|
||||
@@ -245,6 +290,36 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
>
|
||||
<div className="px-4 py-3">
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
{/* Scope toggle */}
|
||||
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScope('session')}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
||||
scope === 'session'
|
||||
? 'bg-white dark:bg-gray-600 text-orange-600 dark:text-orange-400 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
aria-label="Search current session"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Session</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScope('global')}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
||||
scope === 'global'
|
||||
? 'bg-white dark:bg-gray-600 text-orange-600 dark:text-orange-400 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
aria-label="Search all memories"
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Global</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
@@ -253,7 +328,7 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search messages..."
|
||||
placeholder={scope === 'global' ? 'Search all memories...' : 'Search messages...'}
|
||||
className="w-full pl-9 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400 focus:border-transparent"
|
||||
aria-label="Search query"
|
||||
/>
|
||||
@@ -269,22 +344,24 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<Button
|
||||
type="button"
|
||||
variant={showFilters ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setShowFilters((prev) => !prev)}
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Toggle filters"
|
||||
aria-expanded={showFilters}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
</Button>
|
||||
{/* Filter toggle (session only) */}
|
||||
{scope === 'session' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={showFilters ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setShowFilters((prev) => !prev)}
|
||||
className="flex items-center gap-1"
|
||||
aria-label="Toggle filters"
|
||||
aria-expanded={showFilters}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
{searchResults.length > 0 && (
|
||||
{/* Navigation buttons (session only) */}
|
||||
{scope === 'session' && searchResults.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">
|
||||
{currentMatchIndex + 1} / {searchResults.length}
|
||||
@@ -381,8 +458,58 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{query && searchResults.length === 0 && (
|
||||
{/* Global search results */}
|
||||
{scope === 'global' && query && (
|
||||
<div className="mt-2 max-h-64 overflow-y-auto">
|
||||
{globalLoading && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||
Searching memories...
|
||||
</div>
|
||||
)}
|
||||
{!globalLoading && globalResults.length === 0 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||
No memories found matching "{query}"
|
||||
</div>
|
||||
)}
|
||||
{!globalLoading && globalResults.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">
|
||||
{globalResults.length} memories found
|
||||
</div>
|
||||
{globalResults.map((result) => (
|
||||
<div
|
||||
key={result.memory.id}
|
||||
className="px-2 py-1.5 bg-white dark:bg-gray-700/50 border border-gray-100 dark:border-gray-600 rounded text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="text-orange-500 dark:text-orange-400 font-medium">
|
||||
{result.memory.memory_type}
|
||||
</span>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{result.memory.agent_id}
|
||||
</span>
|
||||
{result.memory.importance > 5 && (
|
||||
<span className="text-yellow-500">
|
||||
{'*'.repeat(Math.min(result.memory.importance - 4, 5))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-700 dark:text-gray-300 line-clamp-2">
|
||||
{highlightSearchMatches(result.memory.content, query)}
|
||||
</div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{result.memory.created_at.split('T')[0]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results message (session search) */}
|
||||
{scope === 'session' && query && searchResults.length === 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||
No messages found matching "{query}"
|
||||
</div>
|
||||
|
||||
@@ -485,7 +485,7 @@ export function ReflectionLog({
|
||||
// Initialize reflection engine with config that allows soul modification
|
||||
await intelligenceClient.reflection.init(config);
|
||||
|
||||
const loadedHistory = await intelligenceClient.reflection.getHistory();
|
||||
const loadedHistory = await intelligenceClient.reflection.getHistory(undefined, agentId);
|
||||
setHistory([...loadedHistory].reverse()); // Most recent first
|
||||
|
||||
const proposals = await intelligenceClient.identity.getPendingProposals(agentId);
|
||||
|
||||
@@ -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">
|
||||
|
||||
204
desktop/src/components/presentation/renderers/ChartRenderer.tsx
Normal file
204
desktop/src/components/presentation/renderers/ChartRenderer.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user