Files
FilesReadSystem/frontend/src/pages/InstructionChat.tsx
dj e5d4724e82 【智能助手增强】
- 新增对话历史管理:MongoDB新增conversations集合,存储用户与AI的对话上下文,支持多轮对话意图延续
- 新增对话历史API(conversation.py):GET/DELETE conversation历史、列出所有会话
- 意图解析增强:支持基于对话历史的意图识别,上下文理解更准确
- 字段提取优化:支持"提取文档中的医院数量"等自然语言模式,智能去除"文档中的"前缀
- 文档对比优化:从指令中提取文件名并精确匹配source_docs,支持"对比A和B两个文档"
- 文档摘要优化:使用LLM生成真实AI摘要而非返回原始文档预览

【Word模板填表核心功能】
- Word模板字段生成:空白Word上传后,自动从源文档(Excel/Word/TXT/MD)内容AI生成字段名
- Word模板填表(_fill_docx):将提取数据写入Word模板表格,支持精确匹配、模糊匹配、追加新行
- 数据润色(_polish_word_filled_data):LLM对多行Excel数据进行统计归纳(合计/平均/极值),转化为专业自然语言描述
- 段落格式输出:使用📌字段名+值段落+分隔线(灰色横线)格式,提升可读性
- 导出链打通:fill_template返回filled_file_path,export直接返回已填好的Word文件

【其他修复】
- 修复Word导出Windows文件锁问题:NamedTemporaryFile改为mkstemp+close
- 修复Word方框非法字符:扩展clean_text移除\uFFFD、□等Unicode替代符和零宽字符
- 修复文档对比"需要至少2个文档":从指令提取具体文件名优先匹配而非取前2个
- 修复导出format硬编码:自动识别docx/xlsx格式
- Docx解析器增加备用解析方法和更完整的段落/表格/标题提取
- RAG服务新增MySQL数据源支持
2026-04-15 23:32:55 +08:00

439 lines
19 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.

import React, { useState, useRef, useEffect } from 'react';
import { Send, Bot, User, Sparkles, Trash2, FileText, TableProperties, ArrowRight, Search, MessageSquare } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Markdown } from '@/components/ui/markdown';
import { backendApi } from '@/db/backend-api';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type ChatMessage = {
id: string;
role: 'user' | 'assistant';
content: string;
created_at: string;
intent?: string;
result?: any;
};
const InstructionChat: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [currentDocIds, setCurrentDocIds] = useState<string[]>([]);
const [conversationId, setConversationId] = useState<string>('');
const scrollAreaRef = useRef<HTMLDivElement>(null);
// 初始化会话ID
useEffect(() => {
const storedId = localStorage.getItem('chat_conversation_id');
if (storedId) {
setConversationId(storedId);
} else {
const newId = `conv_${Date.now()}_${Math.random().toString(36).substring(7)}`;
setConversationId(newId);
localStorage.setItem('chat_conversation_id', newId);
}
}, []);
useEffect(() => {
// Initial welcome message
if (messages.length === 0) {
setMessages([
{
id: 'welcome',
role: 'assistant',
content: `您好!我是智联文档 AI 助手。
**📄 文档智能操作**
- "提取文档中的医院数量和床位数"
- "帮我找出所有机构的名称"
**📊 数据填表**
- "根据这些数据填表"
- "将提取的信息填写到Excel模板"
**📝 内容处理**
- "总结一下这份文档"
- "对比这两个文档的差异"
**🔍 智能问答**
- "文档里说了些什么?"
- "有多少家医院?"
请告诉我您想做什么?`,
created_at: new Date().toISOString()
}
]);
// 获取已上传的文档ID列表
loadDocuments();
}
}, []);
const loadDocuments = async () => {
try {
const result = await backendApi.getDocuments(undefined, 50);
if (result.success && result.documents) {
const docIds = result.documents.map((d: any) => d.doc_id);
setCurrentDocIds(docIds);
if (docIds.length > 0) {
console.log(`已加载 ${docIds.length} 个文档`);
}
}
} catch (err) {
console.error('获取文档列表失败:', err);
}
};
useEffect(() => {
// Scroll to bottom
if (scrollAreaRef.current) {
const scrollElement = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (scrollElement) {
scrollElement.scrollTop = scrollElement.scrollHeight;
}
}
}, [messages]);
const handleSend = async () => {
if (!input.trim()) return;
const userMessage: ChatMessage = {
id: Math.random().toString(36).substring(7),
role: 'user',
content: input.trim(),
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setLoading(true);
try {
// 使用真实的智能指令 API
const response = await backendApi.instructionChat(
input.trim(),
currentDocIds.length > 0 ? currentDocIds : undefined,
{ conversation_id: conversationId }
);
// 根据意图类型生成友好响应
let responseContent = '';
const resultData = response.result;
switch (response.intent) {
case 'extract':
// 信息提取结果
const extracted = resultData?.extracted_data || {};
const keys = Object.keys(extracted);
if (keys.length > 0) {
responseContent = `✅ 已提取到 ${keys.length} 个字段的数据:\n\n`;
for (const [key, value] of Object.entries(extracted)) {
const values = Array.isArray(value) ? value : [value];
const displayValues = values.length > 10 ? values.slice(0, 10).join(', ') + ` ...(共${values.length}条)` : values.join(', ');
responseContent += `**${key}**: ${displayValues}\n`;
}
responseContent += `\n💡 可直接使用以上数据,或说"填入表格"继续填表操作。`;
} else {
responseContent = resultData?.message || '未能从文档中提取到相关数据。请尝试更明确的字段名称。';
}
break;
case 'fill_table':
// 填表结果
const filled = resultData?.result?.filled_data || {};
const filledKeys = Object.keys(filled);
if (filledKeys.length > 0) {
responseContent = `✅ 填表完成!成功填写 ${filledKeys.length} 个字段:\n\n`;
for (const [key, value] of Object.entries(filled)) {
const values = Array.isArray(value) ? value : [value];
const displayValues = values.length > 10 ? values.slice(0, 10).join(', ') + ` ...(共${values.length}条)` : values.join(', ');
responseContent += `**${key}**: ${displayValues}\n`;
}
responseContent += `\n📋 请到【智能填表】页面查看或导出结果。`;
} else {
responseContent = resultData?.message || '填表未能提取到数据。请检查模板表头和数据源内容。';
}
break;
case 'summarize':
// 摘要结果
if (resultData?.action_needed === 'provide_document' || resultData?.action_needed === 'upload_document') {
responseContent = `📋 ${resultData.message}\n\n${resultData.suggestion || ''}`;
} else if (resultData?.ai_summary) {
// AI 生成的摘要
responseContent = `📄 **${resultData.filename}** 摘要分析:\n\n${resultData.ai_summary}`;
} else {
responseContent = resultData?.message || '未能生成摘要。请确保已上传文档。';
}
break;
case 'question':
// 问答结果
if (resultData?.answer) {
responseContent = `**问题**: ${resultData.question}\n\n**答案**: ${resultData.answer}`;
} else if (resultData?.context_preview) {
responseContent = `**问题**: ${resultData.question}\n\n**相关上下文**\n${resultData.context_preview}`;
} else {
responseContent = resultData?.message || '请先上传文档,我才能回答您的问题。';
}
break;
case 'search':
// 搜索结果
const searchResults = resultData?.results || [];
if (searchResults.length > 0) {
responseContent = `🔍 找到 ${searchResults.length} 条相关内容:\n\n`;
searchResults.slice(0, 5).forEach((r: any, idx: number) => {
responseContent += `**${idx + 1}.** ${r.content?.substring(0, 100)}...\n\n`;
});
} else {
responseContent = '未找到相关内容。请尝试其他关键词。';
}
break;
case 'compare':
// 对比结果
const comparison = resultData?.comparison || [];
if (comparison.length > 0) {
responseContent = `📊 对比了 ${comparison.length} 个文档:\n\n`;
comparison.forEach((c: any) => {
responseContent += `- **${c.filename}**: ${c.doc_type}, ${c.content_length}\n`;
});
} else {
responseContent = '需要至少2个文档才能进行对比。';
}
break;
case 'edit':
// 文档编辑结果
if (resultData?.edited_content) {
responseContent = `✏️ **${resultData.original_filename}** 编辑完成:\n\n${resultData.edited_content.substring(0, 500)}${resultData.edited_content.length > 500 ? '\n\n...(内容已截断)' : ''}`;
} else {
responseContent = resultData?.message || '编辑完成。';
}
break;
case 'transform':
// 格式转换结果
if (resultData?.excel_data) {
responseContent = `🔄 格式转换完成!\n\n已转换为 **Excel** 格式,共 **${resultData.excel_data.length}** 行数据。\n\n${resultData.message || ''}`;
} else if (resultData?.content) {
responseContent = `🔄 格式转换完成!\n\n目标格式: **${resultData.target_format?.toUpperCase()}**\n\n${resultData.message || ''}`;
} else {
responseContent = resultData?.message || '格式转换完成。';
}
break;
case 'unknown':
// 检查是否需要用户上传文档
if (resultData?.suggestion) {
responseContent = resultData.suggestion;
} else if (resultData?.message && resultData.message !== '无法理解该指令,请尝试更明确的描述') {
responseContent = resultData.message;
} else {
responseContent = `我理解您想要: "${input.trim()}"\n\n请尝试以下操作\n\n1. **提取数据**: "提取医院数量和床位数"\n2. **填表**: "根据这些数据填表"\n3. **总结**: "总结这份文档"\n4. **问答**: "文档里说了什么?"\n5. **搜索**: "搜索相关内容"`;
}
break;
default:
responseContent = response.message || resultData?.message || '已完成您的请求。';
}
const assistantMessage: ChatMessage = {
id: Math.random().toString(36).substring(7),
role: 'assistant',
content: responseContent,
created_at: new Date().toISOString(),
intent: response.intent,
result: resultData
};
setMessages(prev => [...prev, assistantMessage]);
} catch (err: any) {
console.error('指令执行失败:', err);
toast.error(err.message || '请求失败,请重试');
const errorMessage: ChatMessage = {
id: Math.random().toString(36).substring(7),
role: 'assistant',
content: `抱歉,处理您的请求时遇到了问题:${err.message}\n\n请稍后重试或尝试更简单的指令。`,
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setLoading(false);
}
};
const clearChat = () => {
setMessages([messages[0]]);
toast.success('对话已清空');
};
const quickActions = [
{ label: '提取医院数量', icon: Search, action: () => setInput('提取文档中的医院数量和床位数') },
{ label: '智能填表', icon: TableProperties, action: () => setInput('根据这些数据填表') },
{ label: '总结文档', icon: MessageSquare, action: () => setInput('总结一下这份文档') },
{ label: '智能问答', icon: Bot, action: () => setInput('文档里说了些什么?') }
];
return (
<div className="h-[calc(100vh-8rem)] flex flex-col gap-6 animate-fade-in relative">
<section className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1">
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<Sparkles className="text-primary animate-pulse" />
</h1>
<p className="text-muted-foreground"></p>
</div>
<Button
variant="outline"
size="sm"
className="rounded-xl gap-2 h-10 border-none bg-card shadow-sm hover:bg-destructive/10 hover:text-destructive"
onClick={clearChat}
>
<Trash2 size={16} />
<span></span>
</Button>
</section>
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
{/* Chat Area */}
<Card className="flex-1 flex flex-col border-none shadow-xl overflow-hidden rounded-3xl bg-card/50 backdrop-blur-sm">
<ScrollArea className="flex-1 p-6" ref={scrollAreaRef}>
<div className="space-y-8 pb-4">
{messages.map((m) => (
<div
key={m.id}
className={cn(
"flex gap-4 max-w-[85%]",
m.role === 'user' ? "ml-auto flex-row-reverse" : "mr-auto"
)}
>
<div className={cn(
"w-10 h-10 rounded-2xl flex items-center justify-center shrink-0 shadow-lg",
m.role === 'user' ? "bg-primary text-primary-foreground" : "bg-white text-primary border border-primary/20"
)}>
{m.role === 'user' ? <User size={20} /> : <Bot size={22} />}
</div>
<div className={cn(
"space-y-2 p-5 rounded-3xl",
m.role === 'user'
? "bg-primary text-primary-foreground shadow-xl shadow-primary/20 rounded-tr-none"
: "bg-white border border-border/50 shadow-md rounded-tl-none"
)}>
{m.role === 'assistant' ? (
<Markdown content={m.content} className="text-sm leading-relaxed prose prose-sm max-w-none" />
) : (
<p className="text-sm leading-relaxed whitespace-pre-wrap font-medium">{m.content}</p>
)}
<span className={cn(
"text-[10px] block opacity-50 font-bold tracking-widest",
m.role === 'user' ? "text-right" : "text-left"
)}>
{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
))}
{loading && (
<div className="flex gap-4 mr-auto max-w-[85%] animate-pulse">
<div className="w-10 h-10 rounded-2xl bg-muted flex items-center justify-center shrink-0 border border-border/50">
<Bot size={22} className="text-muted-foreground" />
</div>
<div className="p-5 rounded-3xl rounded-tl-none bg-muted/50 border border-border/50">
<div className="flex gap-2">
<div className="w-2 h-2 rounded-full bg-primary/40 animate-bounce [animation-delay:-0.3s]" />
<div className="w-2 h-2 rounded-full bg-primary/40 animate-bounce [animation-delay:-0.15s]" />
<div className="w-2 h-2 rounded-full bg-primary/40 animate-bounce" />
</div>
</div>
</div>
)}
</div>
</ScrollArea>
<CardContent className="p-6 bg-white/50 backdrop-blur-xl border-t border-border/50">
<form
onSubmit={(e) => { e.preventDefault(); handleSend(); }}
className="w-full flex gap-3 bg-muted/30 p-2 rounded-2xl border border-border/50 focus-within:border-primary/50 transition-all shadow-inner"
>
<Input
placeholder="尝试输入:帮我分析最近上传的 Excel 文件..."
className="flex-1 bg-transparent border-none focus-visible:ring-0 shadow-none h-12 text-base font-medium"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={loading}
/>
<Button
type="submit"
size="icon"
className="w-12 h-12 rounded-xl bg-primary hover:scale-105 transition-all shadow-lg shadow-primary/20"
disabled={loading || !input.trim()}
>
<Send size={20} />
</Button>
</form>
</CardContent>
</Card>
{/* Quick Actions Panel */}
<aside className="w-full lg:w-80 space-y-6">
<Card className="border-none shadow-lg rounded-3xl bg-gradient-to-br from-primary/5 via-background to-background">
<CardHeader className="p-6">
<CardTitle className="text-sm font-bold uppercase tracking-widest text-primary flex items-center gap-2">
<Sparkles size={16} />
</CardTitle>
</CardHeader>
<CardContent className="p-6 pt-0 space-y-3">
{quickActions.map((action, i) => (
<button
key={i}
className="w-full flex items-center gap-3 p-3 rounded-2xl hover:bg-white hover:shadow-md transition-all group text-left border border-transparent hover:border-primary/10"
onClick={action.action}
>
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<action.icon size={16} />
</div>
<span className="text-sm font-semibold truncate group-hover:text-primary transition-colors">{action.label}</span>
<ArrowRight size={14} className="ml-auto opacity-0 group-hover:opacity-100 -translate-x-2 group-hover:translate-x-0 transition-all" />
</button>
))}
</CardContent>
</Card>
<Card className="border-none shadow-lg rounded-3xl bg-gradient-to-br from-indigo-500/10 to-blue-500/10 overflow-hidden relative">
<div className="absolute top-0 right-0 p-4 opacity-20">
<Sparkles size={100} />
</div>
<CardHeader className="p-6 relative z-10">
<CardTitle className="text-lg font-bold"></CardTitle>
</CardHeader>
<CardContent className="p-6 pt-0 relative z-10 space-y-4 text-sm text-muted-foreground">
<div className="flex items-start gap-2">
<FileText size={16} className="mt-0.5 text-blue-500 shrink-0" />
<span> docx/md/txt MongoDB</span>
</div>
<div className="flex items-start gap-2">
<TableProperties size={16} className="mt-0.5 text-emerald-500 shrink-0" />
<span> xlsx MySQL</span>
</div>
<div className="flex items-start gap-2">
<Sparkles size={16} className="mt-0.5 text-indigo-500 shrink-0" />
<span>使 RAG </span>
</div>
</CardContent>
</Card>
</aside>
</div>
</div>
);
};
export default InstructionChat;