【智能助手增强】

- 新增对话历史管理: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数据源支持
This commit is contained in:
dj
2026-04-15 23:32:55 +08:00
parent 9e7f9df384
commit e5d4724e82
19 changed files with 2185 additions and 407 deletions

View File

@@ -781,7 +781,8 @@ export const backendApi = {
async exportFilledTemplate(
templateId: string,
filledData: Record<string, any>,
format: 'xlsx' | 'docx' = 'xlsx'
format: 'xlsx' | 'docx' = 'xlsx',
filledFilePath?: string
): Promise<Blob> {
const url = `${BACKEND_BASE_URL}/templates/export`;
@@ -793,6 +794,7 @@ export const backendApi = {
template_id: templateId,
filled_data: filledData,
format,
...(filledFilePath && { filled_file_path: filledFilePath }),
}),
});
@@ -964,6 +966,101 @@ export const backendApi = {
throw error;
}
},
// ==================== 智能指令 API ====================
/**
* 智能对话(支持多轮对话的指令执行)
*/
async instructionChat(
instruction: string,
docIds?: string[],
context?: Record<string, any>
): Promise<{
success: boolean;
intent: string;
result: Record<string, any>;
message: string;
hint?: string;
}> {
const url = `${BACKEND_BASE_URL}/instruction/chat`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instruction, doc_ids: docIds, context }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '对话处理失败');
}
return await response.json();
} catch (error) {
console.error('对话处理失败:', error);
throw error;
}
},
/**
* 获取支持的指令类型列表
*/
async getSupportedIntents(): Promise<{
intents: Array<{
intent: string;
name: string;
examples: string[];
params: string[];
}>;
}> {
const url = `${BACKEND_BASE_URL}/instruction/intents`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('获取指令列表失败');
return await response.json();
} catch (error) {
console.error('获取指令列表失败:', error);
throw error;
}
},
/**
* 执行指令(同步模式)
*/
async executeInstruction(
instruction: string,
docIds?: string[],
context?: Record<string, any>
): Promise<{
success: boolean;
intent: string;
result: Record<string, any>;
message: string;
}> {
const url = `${BACKEND_BASE_URL}/instruction/execute`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instruction, doc_ids: docIds, context }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '指令执行失败');
}
return await response.json();
} catch (error) {
console.error('指令执行失败:', error);
throw error;
}
},
};
// ==================== AI 分析 API ====================
@@ -1529,61 +1626,66 @@ export const aiApi = {
}
},
// ==================== 对话历史 API ====================
/**
* 智能对话(支持多轮对话的指令执行)
* 获取对话历史
*/
async instructionChat(
instruction: string,
docIds?: string[],
context?: Record<string, any>
): Promise<{
async getConversationHistory(conversationId: string, limit: number = 20): Promise<{
success: boolean;
intent: string;
result: Record<string, any>;
message: string;
hint?: string;
}> {
const url = `${BACKEND_BASE_URL}/instruction/chat`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instruction, doc_ids: docIds, context }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '对话处理失败');
}
return await response.json();
} catch (error) {
console.error('对话处理失败:', error);
throw error;
}
},
/**
* 获取支持的指令类型列表
*/
async getSupportedIntents(): Promise<{
intents: Array<{
intent: string;
name: string;
examples: string[];
params: string[];
messages: Array<{
role: string;
content: string;
intent?: string;
created_at: string;
}>;
}> {
const url = `${BACKEND_BASE_URL}/instruction/intents`;
const url = `${BACKEND_BASE_URL}/conversation/${conversationId}/history?limit=${limit}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('获取指令列表失败');
if (!response.ok) throw new Error('获取对话历史失败');
return await response.json();
} catch (error) {
console.error('获取指令列表失败:', error);
throw error;
console.error('获取对话历史失败:', error);
return { success: false, messages: [] };
}
},
/**
* 删除对话历史
*/
async deleteConversation(conversationId: string): Promise<{
success: boolean;
}> {
const url = `${BACKEND_BASE_URL}/conversation/${conversationId}`;
try {
const response = await fetch(url, { method: 'DELETE' });
if (!response.ok) throw new Error('删除对话历史失败');
return await response.json();
} catch (error) {
console.error('删除对话历史失败:', error);
return { success: false };
}
},
/**
* 获取会话列表
*/
async listConversations(limit: number = 50): Promise<{
success: boolean;
conversations: Array<any>;
}> {
const url = `${BACKEND_BASE_URL}/conversation/all?limit=${limit}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('获取会话列表失败');
return await response.json();
} catch (error) {
console.error('获取会话列表失败:', error);
return { success: false, conversations: [] };
}
}
};

View File

@@ -15,12 +15,14 @@ import {
Sparkles,
Database,
FileSpreadsheet,
RefreshCcw
RefreshCcw,
Trash2
} from 'lucide-react';
import { backendApi } from '@/db/backend-api';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
type DocumentItem = {
doc_id: string;
@@ -108,7 +110,7 @@ const Dashboard: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ label: '已上传文档', value: stats.docs, icon: FileText, color: 'bg-blue-500', trend: '非结构化文档', link: '/documents' },
{ label: 'Excel 文件', value: stats.excelFiles, icon: FileSpreadsheet, color: 'bg-emerald-500', trend: '结构化数据', link: '/excel-parse' },
{ label: 'Excel 文件', value: stats.excelFiles, icon: FileSpreadsheet, color: 'bg-emerald-500', trend: '结构化数据', link: '/documents' },
{ label: '填表任务', value: stats.tasks, icon: TableProperties, color: 'bg-indigo-500', trend: '待实现', link: '/form-fill' }
].map((stat, i) => (
<Card key={i} className="border-none shadow-md overflow-hidden group hover:shadow-xl transition-all duration-300">
@@ -164,8 +166,30 @@ const Dashboard: React.FC = () => {
{doc.doc_type.toUpperCase()} {formatDistanceToNow(new Date(doc.created_at), { addSuffix: true, locale: zhCN })}
</p>
</div>
<div className="px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-muted">
{doc.doc_type}
<div className="flex items-center gap-2">
<div className="px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-muted">
{doc.doc_type}
</div>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:bg-destructive/10 transition-opacity"
onClick={async (e) => {
e.stopPropagation();
if (!confirm(`确定要删除 "${doc.original_filename}" 吗?`)) return;
try {
const result = await backendApi.deleteDocument(doc.doc_id);
if (result.success) {
setRecentDocs(prev => prev.filter(d => d.doc_id !== doc.doc_id));
toast.success('文档已删除');
}
} catch (err: any) {
toast.error(err.message || '删除失败');
}
}}
>
<Trash2 size={16} />
</Button>
</div>
</div>
))}
@@ -197,7 +221,7 @@ const Dashboard: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[
{ title: '上传文档', desc: '支持 docx/md/txt', icon: FileText, link: '/documents', color: 'bg-blue-500' },
{ title: '解析 Excel', desc: '上传并分析数据', icon: FileSpreadsheet, link: '/excel-parse', color: 'bg-emerald-500' },
{ title: '解析 Excel', desc: '上传并分析数据', icon: FileSpreadsheet, link: '/documents', color: 'bg-emerald-500' },
{ title: '智能填表', desc: '自动填写表格模板', icon: TableProperties, link: '/form-fill', color: 'bg-indigo-500' },
{ title: 'AI 助手', desc: '自然语言交互', icon: MessageSquareCode, link: '/assistant', color: 'bg-amber-500' }
].map((item, i) => (

View File

@@ -78,6 +78,19 @@ const Documents: React.FC = () => {
const [expandedSheet, setExpandedSheet] = useState<string | null>(null);
const [uploadExpanded, setUploadExpanded] = useState(false);
// 批量上传状态跟踪
type FileUploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'failed';
interface UploadFileState {
file: File;
status: FileUploadStatus;
progress: number;
taskId?: string;
error?: string;
docId?: string;
}
const [uploadStates, setUploadStates] = useState<UploadFileState[]>([]);
const [batchTaskId, setBatchTaskId] = useState<string | null>(null);
// AI 分析相关状态
const [analyzing, setAnalyzing] = useState(false);
const [analyzingForCharts, setAnalyzingForCharts] = useState(false);
@@ -211,21 +224,119 @@ const Documents: React.FC = () => {
}
};
// 文件上传处理
// 文件上传处理 - 批量上传
const onDrop = async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
// 初始化上传状态
const initialStates: UploadFileState[] = acceptedFiles.map(file => ({
file,
status: 'pending',
progress: 0
}));
setUploadStates(initialStates);
setUploadExpanded(true);
setUploading(true);
try {
// 使用批量上传接口
const result = await backendApi.uploadDocuments(acceptedFiles);
if (result.task_id) {
setBatchTaskId(result.task_id);
// 更新所有文件状态为上传中
setUploadStates(prev => prev.map(s => ({ ...s, status: 'uploading', progress: 30 })));
// 轮询任务状态
let attempts = 0;
const maxAttempts = 150; // 最多5分钟
const checkBatchStatus = async () => {
while (attempts < maxAttempts) {
try {
const status = await backendApi.getTaskStatus(result.task_id);
if (status.status === 'success' && status.result) {
// 更新每个文件的状态
const fileResults = status.result.results || [];
setUploadStates(prev => prev.map((s, idx) => {
const fileResult = fileResults[idx];
if (fileResult?.success) {
return { ...s, status: 'success', progress: 100, docId: fileResult.doc_id };
} else {
return { ...s, status: 'failed', progress: 0, error: fileResult?.error || '处理失败' };
}
}));
loadDocuments();
return;
} else if (status.status === 'failure') {
setUploadStates(prev => prev.map(s => ({
...s,
status: 'failed',
error: status.error || '批量处理失败'
})));
return;
} else {
// 处理中 - 更新进度
const progress = status.progress || Math.min(30 + attempts * 2, 90);
setUploadStates(prev => prev.map(s => ({
...s,
status: s.status === 'uploading' ? 'processing' : s.status,
progress
})));
}
} catch (e) {
console.error('检查批量状态失败', e);
}
await new Promise(resolve => setTimeout(resolve, 2000));
attempts++;
}
// 超时
setUploadStates(prev => prev.map(s => {
if (s.status !== 'success') {
return { ...s, status: 'failed', error: '处理超时' };
}
return s;
}));
};
checkBatchStatus();
} else {
// 单文件直接上传(旧逻辑作为后备)
await handleSingleFileUploads(acceptedFiles);
}
} catch (error: any) {
toast.error(error.message || '上传失败');
setUploadStates(prev => prev.map(s => ({
...s,
status: 'failed',
error: error.message || '上传失败'
})));
} finally {
setUploading(false);
}
};
// 单文件上传后备逻辑
const handleSingleFileUploads = async (files: File[]) => {
let successCount = 0;
let failCount = 0;
const successfulFiles: File[] = [];
// 逐个上传文件
for (const file of acceptedFiles) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const ext = file.name.split('.').pop()?.toLowerCase();
setUploadStates(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: 'uploading' } : s
));
try {
if (ext === 'xlsx' || ext === 'xls') {
setUploadStates(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: 'processing', progress: 50 } : s
));
const result = await backendApi.uploadExcel(file, {
parseAllSheets: parseOptions.parseAllSheets,
headerRow: parseOptions.headerRow
@@ -233,99 +344,60 @@ const Documents: React.FC = () => {
if (result.success) {
successCount++;
successfulFiles.push(file);
// 第一个Excel文件设置解析结果供预览
setUploadStates(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: 'success', progress: 100 } : s
));
if (successCount === 1) {
setUploadedFile(file);
setParseResult(result);
if (result.metadata?.sheet_count === 1) {
setExpandedSheet(Object.keys(result.data?.sheets || {})[0] || null);
}
}
loadDocuments();
} else {
failCount++;
toast.error(`${file.name}: ${result.error || '解析失败'}`);
}
} else if (ext === 'md' || ext === 'markdown') {
const result = await backendApi.uploadDocument(file);
if (result.task_id) {
successCount++;
successfulFiles.push(file);
if (successCount === 1) {
setUploadedFile(file);
}
// 轮询任务状态
let attempts = 0;
const checkStatus = async () => {
while (attempts < 30) {
try {
const status = await backendApi.getTaskStatus(result.task_id);
if (status.status === 'success') {
loadDocuments();
return;
} else if (status.status === 'failure') {
return;
}
} catch (e) {
console.error('检查状态失败', e);
}
await new Promise(resolve => setTimeout(resolve, 2000));
attempts++;
}
};
checkStatus();
} else {
failCount++;
setUploadStates(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: 'failed', error: result.error || '解析失败' } : s
));
}
} else {
// 其他文档使用通用上传接口
setUploadStates(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: 'processing', progress: 50 } : s
));
const result = await backendApi.uploadDocument(file);
if (result.task_id) {
successCount++;
successfulFiles.push(file);
if (successCount === 1) {
setUploadedFile(file);
}
// 轮询任务状态
// 等待任务完成
let attempts = 0;
const checkStatus = async () => {
while (attempts < 30) {
try {
const status = await backendApi.getTaskStatus(result.task_id);
if (status.status === 'success') {
loadDocuments();
return;
} else if (status.status === 'failure') {
return;
}
} catch (e) {
console.error('检查状态失败', e);
while (attempts < 60) {
const status = await backendApi.getTaskStatus(result.task_id);
if (status.status === 'success') {
successCount++;
successfulFiles.push(file);
setUploadStates(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: 'success', progress: 100, docId: status.result?.doc_id } : s
));
if (successCount === 1) {
setUploadedFile(file);
}
await new Promise(resolve => setTimeout(resolve, 2000));
attempts++;
loadDocuments();
break;
} else if (status.status === 'failure') {
setUploadStates(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: 'failed', error: status.error || '处理失败' } : s
));
break;
}
};
checkStatus();
} else {
failCount++;
await new Promise(resolve => setTimeout(resolve, 2000));
attempts++;
}
}
}
} catch (error: any) {
failCount++;
toast.error(`${file.name}: ${error.message || '上传失败'}`);
setUploadStates(prev => prev.map((s, idx) =>
idx === i ? { ...s, status: 'failed', error: error.message || '上传失败' } : s
));
}
}
setUploading(false);
loadDocuments();
if (successCount > 0) {
toast.success(`成功上传 ${successCount} 个文件`);
setUploadedFiles(prev => [...prev, ...successfulFiles]);
setUploadExpanded(true);
}
if (failCount > 0) {
toast.error(`${failCount} 个文件上传失败`);
}
};
@@ -699,7 +771,110 @@ const Documents: React.FC = () => {
</CardHeader>
{uploadPanelOpen && (
<CardContent className="space-y-4">
{uploadedFiles.length > 0 || uploadedFile ? (
{/* 优先显示正在上传的状态 */}
{uploadStates.length > 0 && (
<div className="space-y-3">
{/* 上传状态头部 */}
<div
className="flex items-center justify-between p-3 bg-primary/5 rounded-xl cursor-pointer hover:bg-primary/10 transition-colors"
onClick={() => setUploadExpanded(!uploadExpanded)}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
{uploading ? <Loader2 size={20} className="animate-spin" /> : <Upload size={20} />}
</div>
<div>
<p className="font-semibold text-sm">
{uploading ? '正在上传' : '上传完成'} {uploadStates.length}
</p>
<p className="text-xs text-muted-foreground">
{uploading ? '上传中,请稍候...' : uploadStates.filter(s => s.status === 'failed').length > 0 ? '部分失败' : '点击查看详情'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!uploading && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setUploadStates([]);
setUploadedFiles([]);
setUploadedFile(null);
}}
className="text-destructive hover:text-destructive"
>
<Trash2 size={14} className="mr-1" />
</Button>
)}
{uploadExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</div>
{/* 上传进度列表(总是展开显示) */}
{uploadExpanded && (
<div className="space-y-2 border rounded-xl p-3 bg-background">
{uploadStates.map((state, index) => (
<div key={index} className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/30 transition-colors">
<div className={cn(
"w-8 h-8 rounded flex items-center justify-center shrink-0",
isExcelFile(state.file.name) ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{state.status === 'pending' && <Clock size={16} />}
{state.status === 'uploading' && <Upload size={16} className="animate-pulse" />}
{state.status === 'processing' && <Loader2 size={16} className="animate-spin" />}
{state.status === 'success' && <CheckCircle size={16} className="text-green-500" />}
{state.status === 'failed' && <AlertCircle size={16} className="text-red-500" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{state.file.name}</p>
<div className="flex items-center gap-2">
{state.status === 'pending' && <p className="text-xs text-muted-foreground">...</p>}
{state.status === 'uploading' && <p className="text-xs text-primary">...</p>}
{state.status === 'processing' && <p className="text-xs text-primary">...</p>}
{state.status === 'failed' && state.error && (
<p className="text-xs text-red-500 truncate">{state.error}</p>
)}
{state.status === 'success' && (
<p className="text-xs text-green-500"></p>
)}
</div>
{/* 进度条 */}
{(state.status === 'uploading' || state.status === 'processing') && (
<div className="mt-1 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${state.progress}%` }}
/>
</div>
)}
</div>
{state.status === 'success' && (
<CheckCircle size={16} className="text-green-500 shrink-0" />
)}
{state.status === 'failed' && (
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 shrink-0"
onClick={() => {
setUploadStates(prev => prev.filter((_, i) => i !== index));
}}
>
<Trash2 size={14} />
</Button>
)}
</div>
))}
</div>
)}
</div>
)}
{/* 已上传文件列表(没有正在上传时显示) */}
{uploadStates.length === 0 && (uploadedFiles.length > 0 || uploadedFile) ? (
<div className="space-y-3">
{/* 文件列表头部 */}
<div
@@ -739,6 +914,84 @@ const Documents: React.FC = () => {
{/* 展开的文件列表 */}
{uploadExpanded && (
<div className="space-y-2 border rounded-xl p-3">
{/* 显示已上传文件列表 */}
{(uploadedFiles.length > 0 ? uploadedFiles : [uploadedFile]).filter(Boolean).map((file, index) => (
<div key={index} className="flex items-center gap-3 p-2 bg-background rounded-lg">
<div className={cn(
"w-8 h-8 rounded flex items-center justify-center",
isExcelFile(file?.name || '') ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isExcelFile(file?.name || '') ? <FileSpreadsheet size={16} /> : <FileText size={16} />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{file?.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(file?.size || 0)}</p>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleRemoveUploadedFile(index)}
>
<Trash2 size={14} />
</Button>
</div>
))}
{/* 继续添加按钮 */}
<div
{...getRootProps()}
className="flex items-center justify-center gap-2 p-3 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<input {...getInputProps()} multiple={true} />
<Plus size={16} className="text-muted-foreground" />
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
)}
</div>
) : (uploadedFiles.length > 0 || uploadedFile) ? (
<div className="space-y-3">
{/* 文件列表头部 */}
<div
className="flex items-center justify-between p-3 bg-muted/50 rounded-xl cursor-pointer hover:bg-muted/70 transition-colors"
onClick={() => setUploadExpanded(!uploadExpanded)}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<Upload size={20} />
</div>
<div>
<p className="font-semibold text-sm">
{(uploadedFiles.length > 0 ? uploadedFiles : [uploadedFile]).length}
</p>
<p className="text-xs text-muted-foreground">
{uploadExpanded ? '点击收起' : '点击展开查看'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile();
}}
className="text-destructive hover:text-destructive"
>
<Trash2 size={14} className="mr-1" />
</Button>
{uploadExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</div>
{/* 展开的文件列表 */}
{uploadExpanded && (
<div className="space-y-2 border rounded-xl p-3">
{/* 显示已上传文件列表 */}
{(uploadedFiles.length > 0 ? uploadedFiles : [uploadedFile]).filter(Boolean).map((file, index) => (
<div key={index} className="flex items-center gap-3 p-2 bg-background rounded-lg">
<div className={cn(

View File

@@ -1,26 +1,10 @@
import React, { useState, useRef, useEffect } from 'react';
import {
Send,
Bot,
User,
Sparkles,
Trash2,
RefreshCcw,
FileText,
TableProperties,
ChevronRight,
ArrowRight,
Loader2,
Download,
Search,
MessageSquare,
CheckCircle
} from 'lucide-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 { Badge } from '@/components/ui/badge';
import { Markdown } from '@/components/ui/markdown';
import { backendApi } from '@/db/backend-api';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -39,8 +23,21 @@ const InstructionChat: React.FC = () => {
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) {
@@ -119,7 +116,8 @@ const InstructionChat: React.FC = () => {
// 使用真实的智能指令 API
const response = await backendApi.instructionChat(
input.trim(),
currentDocIds.length > 0 ? currentDocIds : undefined
currentDocIds.length > 0 ? currentDocIds : undefined,
{ conversation_id: conversationId }
);
// 根据意图类型生成友好响应
@@ -135,11 +133,12 @@ const InstructionChat: React.FC = () => {
responseContent = `✅ 已提取到 ${keys.length} 个字段的数据:\n\n`;
for (const [key, value] of Object.entries(extracted)) {
const values = Array.isArray(value) ? value : [value];
responseContent += `**${key}**: ${values.slice(0, 3).join(', ')}${values.length > 3 ? '...' : ''}\n`;
const displayValues = values.length > 10 ? values.slice(0, 10).join(', ') + ` ...(共${values.length}条)` : values.join(', ');
responseContent += `**${key}**: ${displayValues}\n`;
}
responseContent += `\n💡 您可以将这些数据填入表格`;
responseContent += `\n💡 可直接使用以上数据,或说"填入表格"继续填表操作`;
} else {
responseContent = '未能从文档中提取到相关数据。请尝试更明确的字段名称。';
responseContent = resultData?.message || '未能从文档中提取到相关数据。请尝试更明确的字段名称。';
}
break;
@@ -151,24 +150,24 @@ const InstructionChat: React.FC = () => {
responseContent = `✅ 填表完成!成功填写 ${filledKeys.length} 个字段:\n\n`;
for (const [key, value] of Object.entries(filled)) {
const values = Array.isArray(value) ? value : [value];
responseContent += `**${key}**: ${values.slice(0, 3).join(', ')}\n`;
const displayValues = values.length > 10 ? values.slice(0, 10).join(', ') + ` ...(共${values.length}条)` : values.join(', ');
responseContent += `**${key}**: ${displayValues}\n`;
}
responseContent += `\n📋 请到【智能填表】页面查看或导出结果。`;
} else {
responseContent = '填表未能提取到数据。请检查模板表头和数据源内容。';
responseContent = resultData?.message || '填表未能提取到数据。请检查模板表头和数据源内容。';
}
break;
case 'summarize':
// 摘要结果
const summaries = resultData?.summaries || [];
if (summaries.length > 0) {
responseContent = `📄 找到 ${summaries.length} 个文档的摘要:\n\n`;
summaries.forEach((s: any, idx: number) => {
responseContent += `**${idx + 1}. ${s.filename}**\n${s.content_preview}\n\n`;
});
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 = '未能生成摘要。请确保已上传文档。';
responseContent = resultData?.message || '未能生成摘要。请确保已上传文档。';
}
break;
@@ -176,8 +175,10 @@ const InstructionChat: React.FC = () => {
// 问答结果
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 || '我找到了相关信息,请查看上文。';
responseContent = resultData?.message || '请先上传文档,我才能回答您的问题。';
}
break;
@@ -207,8 +208,35 @@ const InstructionChat: React.FC = () => {
}
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':
responseContent = `我理解您想要: "${input.trim()}"\n\n但我目前无法完成此操作。您可以尝试\n\n1. **提取数据**: "提取医院数量和床位数"\n2. **填表**: "根据这些数据填表"\n3. **总结**: "总结这份文档"\n4. **问答**: "文档里说了什么?"\n5. **搜索**: "搜索相关内容"`;
// 检查是否需要用户上传文档
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:
@@ -299,9 +327,11 @@ const InstructionChat: React.FC = () => {
? "bg-primary text-primary-foreground shadow-xl shadow-primary/20 rounded-tr-none"
: "bg-white border border-border/50 shadow-md rounded-tl-none"
)}>
<p className="text-sm leading-relaxed whitespace-pre-wrap font-medium">
{m.content}
</p>
{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"

View File

@@ -248,15 +248,25 @@ const TemplateFill: React.FC = () => {
if (!templateFile || !filledResult) return;
try {
const ext = templateFile.name.split('.').pop()?.toLowerCase();
const exportFormat = (ext === 'docx') ? 'docx' : 'xlsx';
// 对于 Word 模板,如果已有填写后的文件(已填入表格单元格),传递其路径以便直接下载
const filledFilePath = (ext === 'docx' && filledResult.filled_file_path)
? filledResult.filled_file_path
: undefined;
const blob = await backendApi.exportFilledTemplate(
templateId || 'temp',
filledResult.filled_data || {},
'xlsx'
exportFormat,
filledFilePath
);
const ext_match = templateFile.name.match(/\.([^.])+$/);
const baseName = ext_match ? templateFile.name.replace(ext_match[0], '') : templateFile.name;
const downloadName = `filled_${baseName}.${exportFormat}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `filled_${templateFile.name}`;
a.download = downloadName;
a.click();
URL.revokeObjectURL(url);
toast.success('导出成功');
@@ -546,7 +556,7 @@ const TemplateFill: React.FC = () => {
</div>
<h3 className="text-xl font-bold mb-2">AI </h3>
<p className="text-muted-foreground text-center max-w-md">
{sourceFiles.length || sourceFilePaths.length} ...
{sourceFiles.length || sourceFilePaths.length || sourceDocIds.length || 0} ...
</p>
</CardContent>
</Card>
@@ -562,7 +572,7 @@ const TemplateFill: React.FC = () => {
</CardTitle>
<CardDescription>
{sourceFiles.length || sourceFilePaths.length}
{filledResult.source_doc_count || sourceFiles.length || sourceFilePaths.length || sourceDocIds.length}
</CardDescription>
</CardHeader>
<CardContent>