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
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:
400
desktop/src/components/pipeline/IntentInput.tsx
Normal file
400
desktop/src/components/pipeline/IntentInput.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user