fix 文档管理模块 & 法规对话模块
This commit is contained in:
@@ -31,11 +31,18 @@ export async function getComplianceResult(
|
||||
export function complianceChat(
|
||||
segmentId: number,
|
||||
query: string,
|
||||
segmentContext: string | undefined,
|
||||
onMessage: (data: SSEMessage) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void
|
||||
): void {
|
||||
void streamSSE<SSEMessage>(`/compliance/chat/${segmentId}`, { query }, onMessage, onError, onComplete);
|
||||
void streamSSE<SSEMessage>(
|
||||
`/compliance/chat/${segmentId}`,
|
||||
{ query, segment_context: segmentContext },
|
||||
onMessage,
|
||||
onError,
|
||||
onComplete,
|
||||
);
|
||||
}
|
||||
|
||||
export type { ComplianceResult, SSEMessage };
|
||||
|
||||
@@ -6,7 +6,11 @@ interface BackendDocumentItem {
|
||||
doc_name: string;
|
||||
status: string;
|
||||
chunk_count: number;
|
||||
size_bytes?: number;
|
||||
summary?: string;
|
||||
updated_at?: string;
|
||||
regulation_type?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface BackendDocumentListResponse {
|
||||
@@ -44,6 +48,7 @@ export interface RegulationSearchResponse {
|
||||
}
|
||||
|
||||
function mapDoc(item: BackendDocumentItem): DocInfo {
|
||||
const sizeMB = item.size_bytes ? (item.size_bytes / (1024 * 1024)).toFixed(1) + 'MB' : '';
|
||||
return {
|
||||
id: item.doc_id,
|
||||
name: item.doc_name,
|
||||
@@ -51,14 +56,23 @@ function mapDoc(item: BackendDocumentItem): DocInfo {
|
||||
status: item.status,
|
||||
updated_at: item.updated_at,
|
||||
download_url: `${API_BASE_URL}/documents/download/${item.doc_id}`,
|
||||
size_text: sizeMB,
|
||||
summary: item.summary,
|
||||
regulation_type: item.regulation_type,
|
||||
version: item.version,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadDocument(file: File): Promise<DocUploadResponse> {
|
||||
export async function uploadDocument(
|
||||
file: File,
|
||||
opts?: { regulationType?: string; version?: string }
|
||||
): Promise<DocUploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('doc_name', file.name);
|
||||
formData.append('generate_summary', 'true');
|
||||
if (opts?.regulationType) formData.append('regulation_type', opts.regulationType);
|
||||
if (opts?.version) formData.append('version', opts.version);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/documents/upload`, {
|
||||
method: 'POST',
|
||||
@@ -92,6 +106,29 @@ export async function getDocumentList(): Promise<DocListResponse> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDocumentStatus(docId: string): Promise<DocUploadResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/documents/status/${docId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status check failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<DocUploadResponse>;
|
||||
}
|
||||
|
||||
export async function deleteDocument(docId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/documents/${docId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryDocument(docId: string): Promise<DocUploadResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/documents/${docId}/retry`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Retry failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<DocUploadResponse>;
|
||||
}
|
||||
|
||||
export async function searchRegulations(query: string, topK: number = 8): Promise<RegulationSearchResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/knowledge/retrieval`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -136,6 +136,9 @@ export interface DocInfo {
|
||||
updated_at?: string;
|
||||
download_url?: string;
|
||||
size_text?: string;
|
||||
summary?: string;
|
||||
regulation_type?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface DocListResponse {
|
||||
@@ -149,6 +152,8 @@ export interface DocUploadResponse {
|
||||
message?: string;
|
||||
num_chunks?: number;
|
||||
summary?: string;
|
||||
regulation_type?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface QuickQuestion {
|
||||
|
||||
@@ -2,15 +2,21 @@ import type { QuickQuestionsResponse, SSEMessage } from './index';
|
||||
|
||||
const AGENT_API_BASE = '/api/v1';
|
||||
|
||||
const _FALLBACK_QUESTIONS = [
|
||||
{ id: '1', question: '请总结最新入库法规对电池安全的核心要求', category: '法规解读' },
|
||||
{ id: '2', question: '我上传的制度文档与新能源法规有哪些潜在冲突?', category: '差距分析' },
|
||||
{ id: '3', question: '请给出法规依据,并按条款列出整改建议', category: '整改建议' },
|
||||
{ id: '4', question: '请解释 UN-ECE 与 GB 标准在网络安全方面的差异', category: '标准对比' },
|
||||
];
|
||||
|
||||
export async function getQuickQuestions(): Promise<QuickQuestionsResponse> {
|
||||
return {
|
||||
questions: [
|
||||
{ id: '1', question: '请总结最新入库法规对电池安全的核心要求', category: '法规解读' },
|
||||
{ id: '2', question: '我上传的制度文档与新能源法规有哪些潜在冲突?', category: '差距分析' },
|
||||
{ id: '3', question: '请给出法规依据,并按条款列出整改建议', category: '整改建议' },
|
||||
{ id: '4', question: '请解释 UN-ECE 与 GB 标准在网络安全方面的差异', category: '标准对比' },
|
||||
],
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`${AGENT_API_BASE}/rag/quick-questions`);
|
||||
if (!response.ok) throw new Error(`status ${response.status}`);
|
||||
return response.json() as Promise<QuickQuestionsResponse>;
|
||||
} catch {
|
||||
return { questions: _FALLBACK_QUESTIONS };
|
||||
}
|
||||
}
|
||||
|
||||
function parseSSEChunk(raw: string, onMessage: (data: SSEMessage) => void) {
|
||||
@@ -67,7 +73,8 @@ export async function ragChat(
|
||||
topK: number = 5,
|
||||
onMessage: (data: SSEMessage) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void
|
||||
onComplete?: () => void,
|
||||
filters?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${AGENT_API_BASE}/agent/chat/stream`, {
|
||||
@@ -76,7 +83,7 @@ export async function ragChat(
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({ query, top_k: topK }),
|
||||
body: JSON.stringify({ query, top_k: topK, ...(filters ? { filters } : {}) }),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
|
||||
@@ -250,11 +250,20 @@ export const CompliancePage: React.FC = () => {
|
||||
setChatInput('');
|
||||
setChatLoading(true);
|
||||
|
||||
const segmentContext = [
|
||||
`意图:${chunk.intent}`,
|
||||
`内容:${chunk.content.slice(0, 300)}`,
|
||||
chunk.regulations.length > 0
|
||||
? `相关法规:${chunk.regulations.slice(0, 3).map(r => `${r.name}${r.clause ? ' ' + r.clause : ''}(相关性 ${Math.round(r.score * 100)}%)`).join(';')}`
|
||||
: '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
let currentResponse = '';
|
||||
|
||||
complianceChat(
|
||||
activeChunkId,
|
||||
chatInput,
|
||||
segmentContext,
|
||||
(data: unknown) => {
|
||||
const sseData = data as { type: string; text?: string };
|
||||
if (sseData.type === 'chunk' && sseData.text) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import { Content } from '../../components/layout/Content';
|
||||
import { TPattern } from '../../components/common/TPattern';
|
||||
import { getDocumentList, searchRegulations, uploadDocument, type RegulationSearchItem } from '../../api/docs';
|
||||
import { getDocumentList, getDocumentStatus, searchRegulations, uploadDocument, deleteDocument, retryDocument, type RegulationSearchItem } from '../../api/docs';
|
||||
import type { Doc } from '../../types';
|
||||
|
||||
type PipelineStatus = 'idle' | 'running' | 'completed' | 'error';
|
||||
@@ -15,13 +15,13 @@ const PIPELINE_STEPS = [
|
||||
{ name: 'STORE' },
|
||||
];
|
||||
|
||||
const REGULATION_TYPES = ['', '国家标准', '行业标准', '地方标准', '企业标准', '法律法规', '监管规定'];
|
||||
|
||||
const STEP_DURATION_MS = 700;
|
||||
const INITIAL_SEARCH_QUERY = '新能源汽车电池安全要求';
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
return new Promise<void>((resolve) => { window.setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
export const DocsPage: React.FC = () => {
|
||||
@@ -41,6 +41,13 @@ export const DocsPage: React.FC = () => {
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
|
||||
// Upload metadata
|
||||
const [regulationType, setRegulationType] = useState('');
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
// Batch queue: files waiting to be uploaded after the current one finishes
|
||||
const batchQueueRef = useRef<File[]>([]);
|
||||
|
||||
async function loadDocuments() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -54,6 +61,9 @@ export const DocsPage: React.FC = () => {
|
||||
docId: doc.id,
|
||||
downloadUrl: doc.download_url,
|
||||
updatedAt: doc.updated_at,
|
||||
summary: doc.summary,
|
||||
regulationType: doc.regulation_type,
|
||||
version: doc.version,
|
||||
}));
|
||||
setDocs(apiDocs);
|
||||
} catch (error) {
|
||||
@@ -81,62 +91,71 @@ export const DocsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
void loadDocuments();
|
||||
}, 0);
|
||||
const timerId = window.setTimeout(() => { void loadDocuments(); }, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
void runSearch(INITIAL_SEARCH_QUERY);
|
||||
}, 0);
|
||||
const timerId = window.setTimeout(() => { void runSearch(INITIAL_SEARCH_QUERY); }, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pipelineRunIdRef.current += 1;
|
||||
};
|
||||
return () => { pipelineRunIdRef.current += 1; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const parsingDocs = docs.filter(
|
||||
(doc) => doc.status === 'parsing' && doc.docId && !doc.docId.startsWith('pending-')
|
||||
);
|
||||
if (parsingDocs.length === 0) return;
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
parsingDocs.forEach((doc) => {
|
||||
void getDocumentStatus(doc.docId!).then((res) => {
|
||||
if (res.status === 'indexed' || res.status === 'failed') {
|
||||
setDocs((prev) =>
|
||||
prev.map((d) =>
|
||||
d.docId === doc.docId
|
||||
? {
|
||||
...d,
|
||||
status: res.status === 'indexed' ? 'indexed' : 'failed',
|
||||
chunks: res.num_chunks ?? d.chunks,
|
||||
summary: res.summary ?? d.summary,
|
||||
regulationType: res.regulation_type ?? d.regulationType,
|
||||
version: res.version ?? d.version,
|
||||
}
|
||||
: d
|
||||
)
|
||||
);
|
||||
}
|
||||
}).catch(() => {});
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [docs]);
|
||||
|
||||
const runPipelineFlow = async (runId: number, uploadPromise: Promise<Awaited<ReturnType<typeof uploadDocument>>>) => {
|
||||
const guardedSetActiveStep = (step: number) => {
|
||||
if (pipelineRunIdRef.current !== runId) return false;
|
||||
setActiveStep(step);
|
||||
return true;
|
||||
};
|
||||
const guard = (fn: () => void) => { if (pipelineRunIdRef.current !== runId) return false; fn(); return true; };
|
||||
|
||||
const guardedCompleteStep = (step: number) => {
|
||||
if (pipelineRunIdRef.current !== runId) return false;
|
||||
setCompletedSteps((prev) => (prev.includes(step) ? prev : [...prev, step]));
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let index = 0; index < PIPELINE_STEPS.length - 1; index += 1) {
|
||||
if (!guardedSetActiveStep(index)) return;
|
||||
for (let i = 0; i < PIPELINE_STEPS.length - 1; i++) {
|
||||
if (!guard(() => setActiveStep(i))) return;
|
||||
await wait(STEP_DURATION_MS);
|
||||
if (!guardedCompleteStep(index)) return;
|
||||
if (!guard(() => setCompletedSteps((p) => p.includes(i) ? p : [...p, i]))) return;
|
||||
}
|
||||
|
||||
if (!guardedSetActiveStep(PIPELINE_STEPS.length - 1)) return;
|
||||
if (!guard(() => setActiveStep(PIPELINE_STEPS.length - 1))) return;
|
||||
await uploadPromise;
|
||||
if (!guardedCompleteStep(PIPELINE_STEPS.length - 1)) return;
|
||||
if (!guard(() => setCompletedSteps((p) => { const last = PIPELINE_STEPS.length - 1; return p.includes(last) ? p : [...p, last]; }))) return;
|
||||
|
||||
await wait(240);
|
||||
if (pipelineRunIdRef.current !== runId) return;
|
||||
|
||||
setActiveStep(-1);
|
||||
setPipelineStatus('completed');
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || uploading) return;
|
||||
|
||||
const runId = pipelineRunIdRef.current + 1;
|
||||
pipelineRunIdRef.current = runId;
|
||||
|
||||
const uploadSingleFile = async (file: File, runId: number) => {
|
||||
setUploading(true);
|
||||
setUploadFileName(file.name);
|
||||
setActiveStep(-1);
|
||||
@@ -152,11 +171,16 @@ export const DocsPage: React.FC = () => {
|
||||
size: `${fileSizeMB}MB`,
|
||||
status: 'parsing',
|
||||
docId: tempDocId,
|
||||
regulationType: regulationType || undefined,
|
||||
version: version || undefined,
|
||||
};
|
||||
|
||||
setDocs((prev) => [newDoc, ...prev]);
|
||||
|
||||
const uploadPromise = uploadDocument(file);
|
||||
const uploadPromise = uploadDocument(file, {
|
||||
regulationType: regulationType || undefined,
|
||||
version: version || undefined,
|
||||
});
|
||||
void runPipelineFlow(runId, uploadPromise);
|
||||
|
||||
try {
|
||||
@@ -166,143 +190,123 @@ export const DocsPage: React.FC = () => {
|
||||
setDocs((prev) =>
|
||||
prev.map((doc) =>
|
||||
doc.id === newDoc.id
|
||||
? {
|
||||
...doc,
|
||||
status: 'indexed',
|
||||
docId: uploadRes.doc_id,
|
||||
chunks: uploadRes.num_chunks || doc.chunks,
|
||||
summary: uploadRes.summary,
|
||||
}
|
||||
? { ...doc, status: 'indexed', docId: uploadRes.doc_id, chunks: uploadRes.num_chunks || doc.chunks, summary: uploadRes.summary }
|
||||
: doc
|
||||
)
|
||||
);
|
||||
|
||||
setUploading(false);
|
||||
setUploadFileName('');
|
||||
void loadDocuments();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
if (pipelineRunIdRef.current !== runId) return;
|
||||
|
||||
setUploading(false);
|
||||
setUploadFileName('');
|
||||
setDocs((prev) => prev.filter((doc) => doc.id !== newDoc.id));
|
||||
setPipelineStatus('error');
|
||||
setActiveStep(-1);
|
||||
setCompletedSteps([]);
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
setUploading(false);
|
||||
setUploadFileName('');
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
|
||||
// Process next file in batch queue
|
||||
const next = batchQueueRef.current.shift();
|
||||
if (next) {
|
||||
const nextRunId = pipelineRunIdRef.current + 1;
|
||||
pipelineRunIdRef.current = nextRunId;
|
||||
void uploadSingleFile(next, nextRunId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
if (uploading) return;
|
||||
fileInputRef.current?.click();
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length === 0 || uploading) return;
|
||||
|
||||
const [first, ...rest] = files;
|
||||
batchQueueRef.current = rest;
|
||||
|
||||
const runId = pipelineRunIdRef.current + 1;
|
||||
pipelineRunIdRef.current = runId;
|
||||
await uploadSingleFile(first, runId);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const handleDelete = async (docId: string) => {
|
||||
try {
|
||||
await deleteDocument(docId);
|
||||
setDocs((prev) => prev.filter((doc) => doc.docId !== docId));
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (docId: string) => {
|
||||
setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'parsing' } : doc));
|
||||
try {
|
||||
const result = await retryDocument(docId);
|
||||
setDocs((prev) =>
|
||||
prev.map((doc) => doc.docId === docId ? { ...doc, status: 'indexed', chunks: result.num_chunks || doc.chunks } : doc)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Retry failed:', error);
|
||||
setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'failed' } : doc));
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileUpload = () => { if (uploading) return; fileInputRef.current?.click(); };
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); };
|
||||
|
||||
const handleDrop = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const files = event.dataTransfer.files;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length === 0 || uploading) return;
|
||||
|
||||
const droppedFile = files[0];
|
||||
if (fileInputRef.current) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(droppedFile);
|
||||
fileInputRef.current.files = dataTransfer.files;
|
||||
}
|
||||
|
||||
void handleFileSelect({
|
||||
target: { files: [droppedFile] as unknown as FileList },
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
const [first, ...rest] = files;
|
||||
batchQueueRef.current = rest;
|
||||
const runId = pipelineRunIdRef.current + 1;
|
||||
pipelineRunIdRef.current = runId;
|
||||
void uploadSingleFile(first, runId);
|
||||
};
|
||||
|
||||
const getStepStyle = (index: number) => {
|
||||
const isActive = activeStep === index;
|
||||
const isCompleted = completedSteps.includes(index);
|
||||
|
||||
if (isActive) {
|
||||
return {
|
||||
background: theme.bgCard,
|
||||
border: `2px solid ${theme.accent}`,
|
||||
boxShadow: `0 0 12px ${theme.accent}40`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return {
|
||||
background: theme.bgCard,
|
||||
border: `1px solid ${theme.green}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
background: theme.bgCard,
|
||||
border: `1px solid ${theme.border}`,
|
||||
};
|
||||
if (activeStep === index) return { background: theme.bgCard, border: `2px solid ${theme.accent}`, boxShadow: `0 0 12px ${theme.accent}40` };
|
||||
if (completedSteps.includes(index)) return { background: theme.bgCard, border: `1px solid ${theme.green}` };
|
||||
return { background: theme.bgCard, border: `1px solid ${theme.border}` };
|
||||
};
|
||||
|
||||
const getCheckStyle = (index: number) => {
|
||||
const isActive = activeStep === index;
|
||||
const isCompleted = completedSteps.includes(index);
|
||||
|
||||
if (isActive) {
|
||||
return {
|
||||
background: theme.gradientAccent,
|
||||
color: '#fff',
|
||||
animation: 'pulse 0.6s infinite',
|
||||
};
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return {
|
||||
background: theme.green,
|
||||
color: '#fff',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
background: theme.bgHover,
|
||||
color: theme.text3,
|
||||
};
|
||||
if (activeStep === index) return { background: theme.gradientAccent, color: '#fff', animation: 'pulse 0.6s infinite' };
|
||||
if (completedSteps.includes(index)) return { background: theme.green, color: '#fff' };
|
||||
return { background: theme.bgHover, color: theme.text3 };
|
||||
};
|
||||
|
||||
const getPipelineHint = () => {
|
||||
if (pipelineStatus === 'running') {
|
||||
return activeStep >= 0 ? `${PIPELINE_STEPS[activeStep].name} · ${uploadFileName}` : `LOAD · ${uploadFileName}`;
|
||||
}
|
||||
if (pipelineStatus === 'completed') {
|
||||
return 'PIPELINE COMPLETE';
|
||||
}
|
||||
if (pipelineStatus === 'error') {
|
||||
return 'PIPELINE FAILED';
|
||||
const queueLen = batchQueueRef.current.length;
|
||||
const suffix = queueLen > 0 ? ` (+${queueLen} 待上传)` : '';
|
||||
return `${activeStep >= 0 ? PIPELINE_STEPS[activeStep].name : 'LOAD'} · ${uploadFileName}${suffix}`;
|
||||
}
|
||||
if (pipelineStatus === 'completed') return 'PIPELINE COMPLETE';
|
||||
if (pipelineStatus === 'error') return 'PIPELINE FAILED';
|
||||
return 'WAITING FOR UPLOAD';
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
background: theme.bgCard,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 8,
|
||||
color: theme.text,
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<TPattern />
|
||||
|
||||
<section style={{ marginBottom: 56 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: theme.accent,
|
||||
marginBottom: 20,
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
UPLOAD
|
||||
</h2>
|
||||
|
||||
@@ -310,10 +314,30 @@ export const DocsPage: React.FC = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
|
||||
<select
|
||||
value={regulationType}
|
||||
onChange={(e) => setRegulationType(e.target.value)}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
>
|
||||
{REGULATION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t || '法规类型(可选)'}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
placeholder="版本号(可选,如 2024)"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={triggerFileUpload}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -330,30 +354,11 @@ export const DocsPage: React.FC = () => {
|
||||
opacity: uploading ? 0.78 : 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 20,
|
||||
background: theme.bgHover,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 20px',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 80, height: 80, borderRadius: 20, background: theme.bgHover, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
{uploading ? (
|
||||
<div style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke={theme.accent}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="60"
|
||||
strokeDashoffset="20"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="10" stroke={theme.accent} strokeWidth="2" strokeDasharray="60" strokeDashoffset="20" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
@@ -363,26 +368,17 @@ export const DocsPage: React.FC = () => {
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 16, fontWeight: 500, marginBottom: 8 }}>
|
||||
{uploading ? '正在上传并启动处理链路...' : '拖拽文件或点击上传'}
|
||||
{uploading ? '正在上传并启动处理链路...' : '拖拽文件或点击上传(支持多选)'}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: theme.text3 }}>
|
||||
{uploading ? uploadFileName : 'PDF · DOCX · DOC · MAX 100MB'}
|
||||
{uploading ? uploadFileName : 'PDF · DOCX · DOC · MAX 100MB · 支持批量'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 40 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: theme.accent,
|
||||
marginBottom: 20,
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
PROCESSING PIPELINE
|
||||
</h2>
|
||||
|
||||
@@ -390,12 +386,7 @@ export const DocsPage: React.FC = () => {
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color:
|
||||
pipelineStatus === 'error'
|
||||
? '#d64545'
|
||||
: pipelineStatus === 'completed'
|
||||
? theme.green
|
||||
: theme.text3,
|
||||
color: pipelineStatus === 'error' ? '#d64545' : pipelineStatus === 'completed' ? theme.green : theme.text3,
|
||||
letterSpacing: '1px',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
@@ -405,63 +396,24 @@ export const DocsPage: React.FC = () => {
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
{PIPELINE_STEPS.map((step, index) => {
|
||||
const stepStyle = getStepStyle(index);
|
||||
const checkStyle = getCheckStyle(index);
|
||||
const arrowActive = activeStep > index || completedSteps.includes(index);
|
||||
const isCompleted = completedSteps.includes(index);
|
||||
const isActive = activeStep === index;
|
||||
const arrowActive = activeStep > index || isCompleted;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.name}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
textAlign: 'center',
|
||||
borderRadius: 12,
|
||||
position: 'relative',
|
||||
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
...stepStyle,
|
||||
}}
|
||||
style={{ flex: 1, padding: 20, textAlign: 'center', borderRadius: 12, position: 'relative', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none', transition: 'all 0.3s ease', ...getStepStyle(index) }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 12px',
|
||||
fontSize: 16,
|
||||
transition: 'all 0.3s ease',
|
||||
...checkStyle,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px', fontSize: 16, transition: 'all 0.3s ease', ...getCheckStyle(index) }}>
|
||||
{isActive ? step.name : isCompleted ? '✓' : step.name}
|
||||
</div>
|
||||
|
||||
<div className="mono" style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
{step.name}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, fontWeight: 600 }}>{step.name}</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: theme.text3, marginTop: 8 }}>
|
||||
{isCompleted ? 'DONE' : isActive ? 'RUNNING' : 'PENDING'}
|
||||
</div>
|
||||
|
||||
{index < PIPELINE_STEPS.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: arrowActive ? theme.green : theme.borderLight,
|
||||
fontWeight: arrowActive ? 700 : 400,
|
||||
opacity: arrowActive ? 1 : 0.45,
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', right: -8, top: '50%', transform: 'translateY(-50%)', color: arrowActive ? theme.green : theme.borderLight, fontWeight: arrowActive ? 700 : 400, opacity: arrowActive ? 1 : 0.45, transition: 'all 0.3s ease' }}>
|
||||
→
|
||||
</div>
|
||||
)}
|
||||
@@ -473,15 +425,7 @@ export const DocsPage: React.FC = () => {
|
||||
|
||||
<section style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 56 }}>
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: theme.accent,
|
||||
marginBottom: 20,
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
文档管理清单 ({loading ? '...' : docs.length})
|
||||
</h2>
|
||||
|
||||
@@ -489,36 +433,12 @@ export const DocsPage: React.FC = () => {
|
||||
{docs.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 20,
|
||||
background: theme.bgCard,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${doc.status === 'parsing' ? theme.accent : theme.border}`,
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 20, background: theme.bgCard, borderRadius: 12, border: `1px solid ${doc.status === 'parsing' ? theme.accent : theme.border}`, transition: 'all 0.2s ease', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: theme.bgHover,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||
<div style={{ width: 44, height: 44, borderRadius: 10, background: theme.bgHover, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M14 2H6C5 2 4 3 4 4V20C4 21 5 22 6 22H18C19 22 20 21 20 20V8L14 2Z"
|
||||
stroke={theme.accent}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M14 2H6C5 2 4 3 4 4V20C4 21 5 22 6 22H18C19 22 20 21 20 20V8L14 2Z" stroke={theme.accent} strokeWidth="1.5" />
|
||||
<path d="M14 2V8H20" stroke={theme.accent} strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -529,36 +449,48 @@ export const DocsPage: React.FC = () => {
|
||||
{doc.updatedAt ? new Date(doc.updatedAt).toLocaleString() : doc.size}
|
||||
{doc.docId ? ` · ${doc.docId}` : ''}
|
||||
</div>
|
||||
{/* Tags row */}
|
||||
{(doc.regulationType || doc.version) && (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 5, flexWrap: 'wrap' }}>
|
||||
{doc.regulationType && (
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, background: `${theme.accent}18`, color: theme.accent, fontWeight: 500 }}>
|
||||
{doc.regulationType}
|
||||
</span>
|
||||
)}
|
||||
{doc.version && (
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, background: theme.bgHover, color: theme.text2 }}>
|
||||
v{doc.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{doc.summary && (
|
||||
<div style={{ fontSize: 12, color: theme.text2, marginTop: 6, lineHeight: 1.5, maxWidth: 320, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{doc.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||
{doc.downloadUrl && (
|
||||
<a
|
||||
href={doc.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ fontSize: 12, color: theme.accent, textDecoration: 'none' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, flexShrink: 0 }}>
|
||||
{doc.status === 'failed' && doc.docId && !doc.docId.startsWith('pending-') && (
|
||||
<button onClick={() => void handleRetry(doc.docId!)} style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', border: `1px solid ${theme.accent}`, borderRadius: 6, color: theme.accent, cursor: 'pointer' }}>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
{doc.downloadUrl && doc.status === 'indexed' && (
|
||||
<a href={doc.downloadUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: theme.accent, textDecoration: 'none' }}>
|
||||
下载
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '6px 12px',
|
||||
background: theme.bgHover,
|
||||
borderRadius: 6,
|
||||
color: doc.status === 'failed' ? '#d64545' : theme.text2,
|
||||
}}
|
||||
>
|
||||
{doc.status === 'parsing'
|
||||
? '处理中...'
|
||||
: doc.status === 'failed'
|
||||
? '处理失败'
|
||||
: `${doc.chunks} chunks`}
|
||||
<div className="mono" style={{ fontSize: 12, padding: '6px 12px', background: theme.bgHover, borderRadius: 6, color: doc.status === 'failed' ? '#d64545' : theme.text2 }}>
|
||||
{doc.status === 'parsing' ? '处理中...' : doc.status === 'failed' ? '处理失败' : `${doc.chunks} chunks`}
|
||||
</div>
|
||||
{doc.docId && !doc.docId.startsWith('pending-') && (
|
||||
<button onClick={() => void handleDelete(doc.docId!)} style={{ fontSize: 12, padding: '6px 10px', background: 'transparent', border: `1px solid ${theme.border}`, borderRadius: 6, color: theme.text3, cursor: 'pointer' }}>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -566,15 +498,7 @@ export const DocsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: theme.accent,
|
||||
marginBottom: 20,
|
||||
letterSpacing: '1px',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||
文档管理内法规检索
|
||||
</h2>
|
||||
|
||||
@@ -582,86 +506,37 @@ export const DocsPage: React.FC = () => {
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
void runSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') void runSearch(searchQuery); }}
|
||||
placeholder="输入法规关键词、条款或制度主题"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
background: theme.bgCard,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 8,
|
||||
color: theme.text,
|
||||
outline: 'none',
|
||||
}}
|
||||
style={{ flex: 1, padding: 12, fontSize: 14, background: theme.bgCard, border: `1px solid ${theme.border}`, borderRadius: 8, color: theme.text, outline: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void runSearch(searchQuery)}
|
||||
disabled={searchLoading || !searchQuery.trim()}
|
||||
style={{
|
||||
padding: '12px 18px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: searchLoading || !searchQuery.trim() ? theme.bgHover : theme.gradientAccent,
|
||||
color: searchLoading || !searchQuery.trim() ? theme.text3 : '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
cursor: searchLoading || !searchQuery.trim() ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
style={{ padding: '12px 18px', fontSize: 13, fontWeight: 600, background: searchLoading || !searchQuery.trim() ? theme.bgHover : theme.gradientAccent, color: searchLoading || !searchQuery.trim() ? theme.text3 : '#fff', border: 'none', borderRadius: 8, cursor: searchLoading || !searchQuery.trim() ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
检索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchError && (
|
||||
<div style={{ marginBottom: 12, fontSize: 13, color: '#d64545' }}>
|
||||
{searchError}
|
||||
</div>
|
||||
)}
|
||||
{searchError && <div style={{ marginBottom: 12, fontSize: 13, color: '#d64545' }}>{searchError}</div>}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{searchResults.map((item) => (
|
||||
<div
|
||||
key={`${item.id}-${item.file}`}
|
||||
style={{
|
||||
padding: 18,
|
||||
background: theme.bgCard,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div key={`${item.id}-${item.file}`} style={{ padding: 18, background: theme.bgCard, borderRadius: 12, border: `1px solid ${theme.border}`, boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: theme.text }}>{item.file}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.accent }}>
|
||||
{(item.score * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.accent }}>{(item.score * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
|
||||
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 8 }}>
|
||||
{item.clause}
|
||||
{item.tags.length > 0 ? ` · ${item.tags.join(' · ')}` : ''}
|
||||
{item.clause}{item.tags.length > 0 ? ` · ${item.tags.join(' · ')}` : ''}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: theme.text2, lineHeight: 1.6 }}>{item.content}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!searchLoading && searchResults.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: theme.bgCard,
|
||||
border: `1px solid ${theme.border}`,
|
||||
textAlign: 'center',
|
||||
color: theme.text3,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 24, borderRadius: 12, background: theme.bgCard, border: `1px solid ${theme.border}`, textAlign: 'center', color: theme.text3 }}>
|
||||
暂无检索结果
|
||||
</div>
|
||||
)}
|
||||
|
||||
58
frontend/src/pages/RagChat/CitedAnswer.tsx
Normal file
58
frontend/src/pages/RagChat/CitedAnswer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import type { RetrievalData } from '../../types';
|
||||
|
||||
interface CitedAnswerProps {
|
||||
text: string;
|
||||
sources: RetrievalData[];
|
||||
onCiteClick: (index: number) => void;
|
||||
}
|
||||
|
||||
export const CitedAnswer: React.FC<CitedAnswerProps> = ({ text, sources, onCiteClick }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
// Split on [N] patterns, preserving delimiters
|
||||
const parts = text.split(/(\[\d+\])/g);
|
||||
|
||||
return (
|
||||
<span style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{parts.map((part, i) => {
|
||||
const match = part.match(/^\[(\d+)\]$/);
|
||||
if (!match) return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||
|
||||
const idx = parseInt(match[1], 10);
|
||||
const source = sources[idx - 1];
|
||||
|
||||
return (
|
||||
<sup
|
||||
key={i}
|
||||
title={source ? `${source.file} · ${source.clause}` : `引用 ${idx}`}
|
||||
onClick={() => onCiteClick(idx)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 4px',
|
||||
marginLeft: 1,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: theme.gradientAccent,
|
||||
color: '#fff',
|
||||
borderRadius: 4,
|
||||
cursor: source ? 'pointer' : 'default',
|
||||
verticalAlign: 'super',
|
||||
lineHeight: 1,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{idx}
|
||||
</sup>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import type { ChatMessage, RetrievalData } from '../../types';
|
||||
import { getQuickQuestions, ragChat } from '../../api/rag';
|
||||
import { CitedAnswer } from './CitedAnswer';
|
||||
|
||||
const ragQuickQuestionsDefault = [
|
||||
'电动自行车上路需要什么条件?',
|
||||
@@ -24,6 +25,8 @@ export const RagChatPage: React.FC = () => {
|
||||
const [showClearConfirm, setShowClearConfirm] = useState<boolean>(false);
|
||||
const [selectedRetrieval, setSelectedRetrieval] = useState<RetrievalData | null>(null);
|
||||
const [quickQuestions, setQuickQuestions] = useState<string[]>(ragQuickQuestionsDefault);
|
||||
const [filterRegulationType, setFilterRegulationType] = useState<string>('');
|
||||
const [highlightedSourceIdx, setHighlightedSourceIdx] = useState<number | null>(null);
|
||||
|
||||
function nextMessageId() {
|
||||
const currentId = nextMessageIdRef.current;
|
||||
@@ -55,8 +58,10 @@ export const RagChatPage: React.FC = () => {
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
setRetrievals([]);
|
||||
setHighlightedSourceIdx(null);
|
||||
|
||||
let currentResponse = '';
|
||||
const activeFilters = filterRegulationType.trim() || undefined;
|
||||
|
||||
void ragChat(
|
||||
text,
|
||||
@@ -112,7 +117,8 @@ export const RagChatPage: React.FC = () => {
|
||||
},
|
||||
() => {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
activeFilters
|
||||
);
|
||||
};
|
||||
|
||||
@@ -130,8 +136,10 @@ export const RagChatPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
setMessages((prev) => [...prev.slice(0, -1)]);
|
||||
setRetrievals([]);
|
||||
setHighlightedSourceIdx(null);
|
||||
|
||||
let currentResponse = '';
|
||||
const activeFilters = filterRegulationType.trim() || undefined;
|
||||
|
||||
void ragChat(
|
||||
lastUserMsg.content,
|
||||
@@ -185,7 +193,8 @@ export const RagChatPage: React.FC = () => {
|
||||
},
|
||||
() => {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
activeFilters
|
||||
);
|
||||
};
|
||||
|
||||
@@ -267,7 +276,17 @@ export const RagChatPage: React.FC = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
border: msg.role === 'assistant' ? `1px solid ${theme.border}` : 'none',
|
||||
}}>
|
||||
{msg.content}
|
||||
{msg.role === 'assistant' ? (
|
||||
<CitedAnswer
|
||||
text={msg.content}
|
||||
sources={retrievals}
|
||||
onCiteClick={(idx) => {
|
||||
setHighlightedSourceIdx(idx);
|
||||
const el = document.getElementById(`source-${idx}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}}
|
||||
/>
|
||||
) : msg.content}
|
||||
{msg.role === 'assistant' && msg.retrievalIds && msg.retrievalIds.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 10,
|
||||
@@ -331,6 +350,31 @@ export const RagChatPage: React.FC = () => {
|
||||
background: theme.bg,
|
||||
borderTop: `1px solid ${theme.border}`,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span className="mono" style={{ fontSize: 11, color: theme.text3, whiteSpace: 'nowrap' }}>法规类型</span>
|
||||
<input
|
||||
value={filterRegulationType}
|
||||
onChange={(e) => setFilterRegulationType(e.target.value)}
|
||||
placeholder="如: GB / UN-ECE / IATF(留空不过滤)"
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 280,
|
||||
padding: '5px 10px',
|
||||
fontSize: 12,
|
||||
background: theme.bgHover,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
color: theme.text,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
@@ -468,14 +512,16 @@ export const RagChatPage: React.FC = () => {
|
||||
{retrievals.map((r, i) => (
|
||||
<div
|
||||
key={r.id}
|
||||
id={`source-${i + 1}`}
|
||||
onClick={() => setSelectedRetrieval(r)}
|
||||
style={{
|
||||
padding: 16,
|
||||
background: theme.bgHover,
|
||||
background: highlightedSourceIdx === i + 1 ? theme.bgElevated : theme.bgHover,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${theme.border}`,
|
||||
border: `1px solid ${highlightedSourceIdx === i + 1 ? theme.accent : theme.border}`,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface Doc {
|
||||
downloadUrl?: string;
|
||||
summary?: string;
|
||||
updatedAt?: string;
|
||||
regulationType?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
|
||||
Reference in New Issue
Block a user