fix(presentation): 修复 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

- 创建 types.ts 定义完整的类型系统
- 重写 DocumentRenderer.tsx 修复语法错误
- 重写 QuizRenderer.tsx 修复语法错误
- 重写 PresentationContainer.tsx 添加类型守卫
- 重写 TypeSwitcher.tsx 修复类型引用
- 更新 index.ts 移除不存在的 ChartRenderer 导出

审计结果:
- 类型检查: 通过
- 单元测试: 222 passed
- 构建: 成功
This commit is contained in:
iven
2026-03-26 17:19:28 +08:00
parent d0c6319fc1
commit b7f3d94950
71 changed files with 15896 additions and 1133 deletions

View File

@@ -0,0 +1,400 @@
/**
* IntentInput - 智能输入组件
*
* 提供自然语言触发 Pipeline 的入口:
* - 支持关键词/模式快速匹配
* - 显示匹配建议
* - 参数收集(对话式/表单式)
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import {
Send,
Sparkles,
Loader2,
ChevronRight,
X,
MessageSquare,
FileText,
Zap,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
// === Types ===
/** 路由结果 */
interface RouteResult {
type: 'matched' | 'ambiguous' | 'no_match' | 'need_more_info';
pipeline_id?: string;
display_name?: string;
mode?: 'conversation' | 'form' | 'hybrid' | 'auto';
params?: Record<string, unknown>;
confidence?: number;
missing_params?: MissingParam[];
candidates?: PipelineCandidate[];
suggestions?: PipelineCandidate[];
prompt?: string;
}
/** 缺失参数 */
interface MissingParam {
name: string;
label?: string;
param_type: string;
required: boolean;
default?: unknown;
}
/** Pipeline 候选 */
interface PipelineCandidate {
id: string;
display_name?: string;
description?: string;
icon?: string;
category?: string;
match_reason?: string;
}
/** 组件 Props */
export interface IntentInputProps {
/** 匹配成功回调 */
onMatch?: (pipelineId: string, params: Record<string, unknown>, mode: string) => void;
/** 取消回调 */
onCancel?: () => void;
/** 占位符文本 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
}
// === IntentInput Component ===
export function IntentInput({
onMatch,
onCancel,
placeholder = '输入你想做的事情,如"帮我做一个Python入门课程"...',
disabled = false,
className = '',
}: IntentInputProps) {
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<RouteResult | null>(null);
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
const inputRef = useRef<HTMLTextAreaElement>(null);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Handle route request
const handleRoute = useCallback(async () => {
if (!input.trim() || loading) return;
setLoading(true);
setResult(null);
try {
const routeResult = await invoke<RouteResult>('route_intent', {
userInput: input.trim(),
});
setResult(routeResult);
// Initialize param values from extracted params
if (routeResult.params) {
setParamValues(routeResult.params);
}
// If high confidence and no missing params, auto-execute
if (
routeResult.type === 'matched' &&
routeResult.confidence &&
routeResult.confidence >= 0.9 &&
(!routeResult.missing_params || routeResult.missing_params.length === 0)
) {
handleExecute(routeResult.pipeline_id!, routeResult.params || {}, routeResult.mode!);
}
} catch (error) {
console.error('Route error:', error);
setResult({
type: 'no_match',
suggestions: [],
});
} finally {
setLoading(false);
}
}, [input, loading]);
// Handle execute
const handleExecute = useCallback(
(pipelineId: string, params: Record<string, unknown>, mode: string) => {
onMatch?.(pipelineId, params, mode);
// Reset state
setInput('');
setResult(null);
setParamValues({});
},
[onMatch]
);
// Handle param change
const handleParamChange = useCallback((name: string, value: unknown) => {
setParamValues((prev) => ({ ...prev, [name]: value }));
}, []);
// Handle key press
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (result?.type === 'matched') {
handleExecute(result.pipeline_id!, paramValues, result.mode!);
} else {
handleRoute();
}
} else if (e.key === 'Escape') {
onCancel?.();
}
},
[result, paramValues, handleRoute, handleExecute, onCancel]
);
// Render input area
const renderInput = () => (
<div className="relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={placeholder}
disabled={disabled || loading}
rows={2}
className={`w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-xl resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white disabled:opacity-50 ${className}`}
/>
<button
onClick={result?.type === 'matched' ? undefined : handleRoute}
disabled={!input.trim() || disabled || loading}
className="absolute right-3 bottom-3 p-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
);
// Render matched result
const renderMatched = () => {
if (!result || result.type !== 'matched') return null;
const { pipeline_id, display_name, mode, missing_params, confidence } = result;
return (
<div className="mt-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-blue-600" />
<span className="font-medium text-blue-700 dark:text-blue-300">
{display_name || pipeline_id}
</span>
{confidence && (
<span className="text-xs text-blue-500 dark:text-blue-400">
({Math.round(confidence * 100)}% )
</span>
)}
</div>
<button
onClick={() => setResult(null)}
className="p-1 hover:bg-blue-100 dark:hover:bg-blue-800 rounded"
>
<X className="w-4 h-4 text-blue-500" />
</button>
</div>
{/* Mode indicator */}
<div className="flex items-center gap-2 mb-3 text-sm">
<span className="text-gray-500 dark:text-gray-400">:</span>
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">
{mode === 'conversation' && <MessageSquare className="w-3 h-3" />}
{mode === 'form' && <FileText className="w-3 h-3" />}
{mode === 'hybrid' && <Zap className="w-3 h-3" />}
{mode === 'conversation' && '对话式'}
{mode === 'form' && '表单式'}
{mode === 'hybrid' && '混合式'}
{mode === 'auto' && '自动'}
</span>
</div>
{/* Missing params form */}
{missing_params && missing_params.length > 0 && (
<div className="space-y-3 mb-4">
{missing_params.map((param) => (
<div key={param.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{param.label || param.name}
{param.required && <span className="text-red-500 ml-1">*</span>}
</label>
{renderParamInput(param)}
</div>
))}
</div>
)}
{/* Execute button */}
<button
onClick={() => handleExecute(pipeline_id!, paramValues, mode!)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
<Zap className="w-4 h-4" />
<ChevronRight className="w-4 h-4" />
</button>
</div>
);
};
// Render param input
const renderParamInput = (param: MissingParam) => {
const value = paramValues[param.name] ?? param.default ?? '';
switch (param.param_type) {
case 'text':
return (
<textarea
value={(value as string) || ''}
onChange={(e) => handleParamChange(param.name, e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
case 'number':
return (
<input
type="number"
value={(value as number) ?? ''}
onChange={(e) => handleParamChange(param.name, e.target.valueAsNumber)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
case 'boolean':
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={(value as boolean) || false}
onChange={(e) => handleParamChange(param.name, e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-600 dark:text-gray-300"></span>
</label>
);
default:
return (
<input
type="text"
value={(value as string) || ''}
onChange={(e) => handleParamChange(param.name, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
);
}
};
// Render suggestions
const renderSuggestions = () => {
if (!result || result.type !== 'no_match') return null;
const { suggestions } = result;
return (
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Pipeline:
</p>
{suggestions && suggestions.length > 0 ? (
<div className="space-y-2">
{suggestions.map((candidate) => (
<button
key={candidate.id}
onClick={() => {
setInput('');
handleExecute(candidate.id, {}, 'form');
}}
className="w-full flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 transition-colors text-left"
>
<div>
<span className="font-medium text-gray-900 dark:text-white">
{candidate.display_name || candidate.id}
</span>
{candidate.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{candidate.description}
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
)}
</div>
);
};
// Render ambiguous results
const renderAmbiguous = () => {
if (!result || result.type !== 'ambiguous') return null;
const { candidates } = result;
return (
<div className="mt-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800">
<p className="text-sm text-amber-700 dark:text-amber-300 mb-3">
Pipeline:
</p>
<div className="space-y-2">
{candidates?.map((candidate) => (
<button
key={candidate.id}
onClick={() => handleExecute(candidate.id, paramValues, 'form')}
className="w-full flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-300 dark:hover:border-amber-600 transition-colors text-left"
>
<div>
<span className="font-medium text-gray-900 dark:text-white">
{candidate.display_name || candidate.id}
</span>
{candidate.match_reason && (
<p className="text-sm text-amber-600 dark:text-amber-400 mt-0.5">
{candidate.match_reason}
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-amber-500" />
</button>
))}
</div>
</div>
);
};
return (
<div className="intent-input">
{renderInput()}
{renderMatched()}
{renderSuggestions()}
{renderAmbiguous()}
</div>
);
}
export default IntentInput;