Files
AIclinicalresearch/frontend-v2/src/modules/dc/pages/tool-c/components/Sidebar.tsx
HaHafeng f729699510 feat(dc): Complete Tool C quick action buttons Phase 1-2 - 7 functions
Summary:
- Implement 7 quick action functions (filter, recode, binning, conditional, dropna, compute, pivot)
- Refactor to pre-written Python functions architecture (stable and secure)
- Add 7 Python operations modules with full type hints
- Add 7 frontend Dialog components with user-friendly UI
- Fix NaN serialization issues and auto type conversion
- Update all related documentation

Technical Details:
- Python: operations/ module (filter.py, recode.py, binning.py, conditional.py, dropna.py, compute.py, pivot.py)
- Backend: QuickActionService.ts with 7 execute methods
- Frontend: 7 Dialog components with complete validation
- Toolbar: Enable 7 quick action buttons

Status: Phase 1-2 completed, basic testing passed, ready for further testing
2025-12-08 17:38:08 +08:00

262 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tool C Sidebar组件
*
* 右侧栏AI Copilot
* Day 5: 使用通用 Chat 组件(基于 Ant Design X + X SDK
*/
import React, { useState, useCallback } from 'react';
import { MessageSquare, X, Upload } from 'lucide-react';
import { StreamingSteps, StreamStep } from './StreamingSteps';
import { App } from 'antd';
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
sessionId: string | null;
onFileUpload: (file: File) => void;
onDataUpdate: (newData: any[]) => void;
}
const Sidebar: React.FC<SidebarProps> = ({
isOpen,
onClose,
sessionId,
onFileUpload,
onDataUpdate,
}) => {
const [inputValue, setInputValue] = useState('');
const [streamSteps, setStreamSteps] = useState<StreamStep[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
// ✅ 使用 App.useApp() hook 获取 message API支持动态主题
const { message } = App.useApp();
if (!isOpen) return null;
/**
* ✨ 流式处理用户请求(自动执行代码)
*/
const handleStreamProcess = useCallback(async (userMessage: string) => {
if (!sessionId) {
message.error('会话未初始化');
return;
}
// ✅ 修复:清空输入框
setInputValue('');
setIsStreaming(true);
setStreamSteps([]);
try {
const response = await fetch('/api/v1/dc/tool-c/ai/stream-process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
message: userMessage,
maxRetries: 3,
}),
});
if (!response.ok) {
throw new Error('请求失败');
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('无法读取响应流');
}
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 处理SSE消息可能有多条
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留最后一个未完成的行
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
setIsStreaming(false);
continue;
}
try {
const step: StreamStep = JSON.parse(data);
// ✅ 修复智能更新步骤同stepName则更新否则追加
setStreamSteps(prev => {
const existingIndex = prev.findIndex(s => s.stepName === step.stepName);
if (existingIndex >= 0) {
// 更新现有步骤
const updated = [...prev];
updated[existingIndex] = step;
return updated;
} else {
// 追加新步骤
return [...prev, step];
}
});
// Step 6完成时更新左侧数据
if (step.stepName === 'complete' && step.status === 'success' && step.data?.newDataPreview) {
onDataUpdate(step.data.newDataPreview);
message.success('数据处理完成!');
}
// 失败时显示错误
if (step.status === 'failed' && step.stepName === 'complete') {
message.error('处理失败:' + (step.error || '未知错误'));
}
} catch (e) {
console.error('解析SSE消息失败:', e);
}
}
}
}
} catch (error: any) {
message.error(error.message || '流式处理失败');
setIsStreaming(false);
}
}, [sessionId, onDataUpdate, message]);
return (
<div
className="w-[480px] bg-white border-l-2 border-slate-200 flex flex-col"
style={{ boxShadow: '-8px 0 16px rgba(0, 0, 0, 0.06)' }} /* ✅ 优化4.1:左侧柔和阴影 */
>
{/* Header */}
<div className="h-14 border-b border-slate-200 flex items-center justify-between px-4 bg-gradient-to-r from-emerald-50 to-white">
<div className="flex items-center gap-2 text-emerald-700 font-semibold">
<MessageSquare size={18} />
<span>AI </span>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors"
title="关闭助手"
>
<X size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 如果没有Session显示上传区域 */}
{!sessionId ? (
<div className="flex-1 flex items-center justify-center p-6">
<div className="text-center space-y-4">
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto">
<Upload className="w-8 h-8 text-emerald-600" />
</div>
<div>
<h3 className="font-semibold text-slate-900 mb-2"></h3>
<p className="text-sm text-slate-500 mb-4">
CSVExcel <br /> 10MB
</p>
</div>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onFileUpload(file);
}}
className="hidden"
id="file-upload-sidebar"
/>
<label
htmlFor="file-upload-sidebar"
className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg cursor-pointer hover:bg-emerald-700 transition-all text-sm font-medium"
>
<Upload size={16} />
</label>
</div>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* ✨ 流式步骤展示区域 */}
<div className="flex-1 overflow-y-auto bg-gradient-to-b from-slate-50 to-white">
{streamSteps.length > 0 ? (
<StreamingSteps
steps={streamSteps}
/>
) : (
/* ✅ 修复:添加欢迎语 */
<div className="flex flex-col items-center justify-center h-full p-6 text-center space-y-4">
<div className="w-16 h-16 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full flex items-center justify-center shadow-lg">
<MessageSquare className="w-8 h-8 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">
👋 AI
</h3>
<p className="text-sm text-slate-600 leading-relaxed max-w-xs">
</p>
</div>
<div className="space-y-2 text-xs text-left text-slate-600 bg-white p-4 rounded-lg border border-slate-200 shadow-sm max-w-sm">
<div> </div>
<div>🔄 </div>
<div>📊 </div>
<div>🧹 </div>
<div> </div>
</div>
<p className="text-xs text-slate-400">
💡
</p>
</div>
)}
</div>
{/* 输入框区域 */}
<div className="border-t border-slate-200 p-4 bg-white">
<div className="flex items-center gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !isStreaming && inputValue.trim()) {
handleStreamProcess(inputValue);
}
}}
placeholder="输入数据处理需求...Enter发送"
disabled={isStreaming}
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 disabled:bg-slate-100 disabled:cursor-not-allowed"
/>
<button
onClick={() => {
if (inputValue.trim() && !isStreaming) {
handleStreamProcess(inputValue);
}
}}
disabled={isStreaming || !inputValue.trim()}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
{isStreaming ? '处理中...' : '发送'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default Sidebar;