Add LLM token
This commit is contained in:
@@ -73,6 +73,22 @@ export interface SSEMessage {
|
||||
text?: string;
|
||||
docs?: RetrievedDoc[];
|
||||
session_id?: string;
|
||||
// ── P0-1 Agentic-mode thinking-step fields ────────────────────────────────
|
||||
// Populated when type === 'thinking'; maps to the backend IntentResult /
|
||||
// GroundingResult / retrieval step payloads emitted by AgenticConversationService.
|
||||
step?: string; // intent_analysis | query_planning | retrieving | grounding_check
|
||||
status?: string; // running | done
|
||||
intent_type?: string; // simple_qa | compare | multi_hop | ambiguous
|
||||
requires_decomposition?: boolean;
|
||||
reason?: string;
|
||||
sub_queries?: string[];
|
||||
query?: string; // sub-query being retrieved
|
||||
index?: number; // 1-based sub-query index
|
||||
total?: number; // total sub-query count
|
||||
found?: number; // chunks found for this sub-query
|
||||
retry?: boolean; // true when this is a grounding-failure re-query
|
||||
sufficient?: boolean; // grounding check result
|
||||
confidence?: number; // grounding confidence 0–1
|
||||
}
|
||||
|
||||
export async function streamSSE<TMessage extends SSEMessage>(
|
||||
|
||||
@@ -76,6 +76,27 @@ function parseSSEChunk(raw: string, onMessage: (data: SSEMessage) => void) {
|
||||
onMessage({ type: 'error', text: joined });
|
||||
} else if (eventName === 'status') {
|
||||
onMessage({ type: 'status', text: joined });
|
||||
} else if (eventName === 'thinking') {
|
||||
// P0-1: Agentic reasoning step events from /agent/agentic/stream
|
||||
try {
|
||||
const payload = JSON.parse(joined) as Record<string, unknown>;
|
||||
onMessage({
|
||||
type: 'thinking',
|
||||
step: payload.step as string | undefined,
|
||||
status: payload.status as string | undefined,
|
||||
intent_type: payload.intent_type as string | undefined,
|
||||
requires_decomposition: payload.requires_decomposition as boolean | undefined,
|
||||
reason: payload.reason as string | undefined,
|
||||
sub_queries: payload.sub_queries as string[] | undefined,
|
||||
query: payload.query as string | undefined,
|
||||
index: payload.index as number | undefined,
|
||||
total: payload.total as number | undefined,
|
||||
found: payload.found as number | undefined,
|
||||
retry: payload.retry as boolean | undefined,
|
||||
sufficient: payload.sufficient as boolean | undefined,
|
||||
confidence: payload.confidence as number | undefined,
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
} else if (eventName === 'message') {
|
||||
// /rag/chat format: event:message + JSON body with type field
|
||||
try {
|
||||
@@ -147,3 +168,73 @@ export async function ragChat(
|
||||
}
|
||||
|
||||
export type { QuickQuestionsResponse, SSEMessage };
|
||||
|
||||
/**
|
||||
* P0-1 Agentic RAG chat — calls /agent/agentic/stream which runs the full
|
||||
* intent-analysis → query-planning → retrieval → grounding-check → answer pipeline.
|
||||
*
|
||||
* The onMessage callback receives the same event types as ragChat plus
|
||||
* ``type: 'thinking'`` events that carry live reasoning-step progress.
|
||||
*/
|
||||
export async function agenticChat(
|
||||
query: string,
|
||||
topK: number = 5,
|
||||
onMessage: (data: SSEMessage) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void,
|
||||
filters?: string,
|
||||
sessionId?: string,
|
||||
signal?: AbortSignal,
|
||||
contextText?: string,
|
||||
contextFilename?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${AGENT_API_BASE}/agent/agentic/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
top_k: topK,
|
||||
...(filters ? { filters } : {}),
|
||||
...(sessionId ? { session_id: sessionId } : {}),
|
||||
...(contextText ? { context_text: contextText, context_filename: contextFilename ?? '' } : {}),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() || '';
|
||||
parseSSEChunk(parts.join('\n\n'), onMessage);
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
parseSSEChunk(buffer, onMessage);
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface ComplianceSourceEvent {
|
||||
score: number;
|
||||
status: string;
|
||||
full_content: string;
|
||||
/** Index of the clause this source was retrieved for (for source↔finding linking) */
|
||||
clause_index?: number;
|
||||
}
|
||||
|
||||
export interface ComplianceFindingEvent {
|
||||
@@ -66,6 +68,17 @@ export interface ComplianceFindingEvent {
|
||||
desc: string;
|
||||
status: 'ok' | 'warn' | 'risk';
|
||||
clause_ref?: string;
|
||||
/** LLM confidence that retrieved context covers the clause topic (0–1) */
|
||||
confidence?: number;
|
||||
/** Top-3 regulation chunks that informed this finding */
|
||||
source_refs?: Array<{ standard: string; clause: string; score: number }>;
|
||||
}
|
||||
|
||||
export interface ComplianceConflict {
|
||||
type: 'contradiction' | 'missing_ref' | 'cumulative_risk';
|
||||
finding_a: number;
|
||||
finding_b: number | null;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface ComplianceActionItem {
|
||||
@@ -103,6 +116,10 @@ export interface ComplianceState {
|
||||
analysisId: string | null;
|
||||
isReadOnly: boolean;
|
||||
activeFindingId: string | null;
|
||||
/** Real-time per-clause progress {done, total} */
|
||||
progress: { done: number; total: number } | null;
|
||||
/** Cross-clause conflicts detected after all findings complete */
|
||||
conflicts: ComplianceConflict[];
|
||||
}
|
||||
|
||||
const COMPLIANCE_INIT: ComplianceState = {
|
||||
@@ -117,6 +134,8 @@ const COMPLIANCE_INIT: ComplianceState = {
|
||||
analysisId: null,
|
||||
isReadOnly: false,
|
||||
activeFindingId: null,
|
||||
progress: null,
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
// ── Perception types ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
ComplianceStatus,
|
||||
ComplianceSourceEvent,
|
||||
ComplianceFindingEvent,
|
||||
ComplianceConflict,
|
||||
ComplianceDonePayload,
|
||||
ComplianceMeta,
|
||||
ComplianceActionItem,
|
||||
|
||||
@@ -238,6 +238,36 @@ export interface Translations {
|
||||
citationsHeader: string;
|
||||
citationsEmpty: string;
|
||||
apiError: string;
|
||||
// ── Agentic mode ─────────────────────────────────────────────────────────
|
||||
agenticMode: string;
|
||||
agenticModeHint: string;
|
||||
agentThinking: string;
|
||||
agentDone: string;
|
||||
stepSuffix: string;
|
||||
stepIntentAnalysis: string;
|
||||
stepQueryPlanning: string;
|
||||
stepRetrieving: string;
|
||||
stepGrounding: string;
|
||||
intentSimpleQa: string;
|
||||
intentCompare: string;
|
||||
intentMultiHop: string;
|
||||
intentAmbiguous: string;
|
||||
intentNeedsDecomposition: string;
|
||||
subQueriesCountSuffix: string;
|
||||
chunksFoundSuffix: string;
|
||||
retryLabel: string;
|
||||
groundingSufficient: string;
|
||||
groundingInsufficient: string;
|
||||
// ── Document attachment in interface ─────────────────────────────────────
|
||||
attachBtn: string;
|
||||
attachExtracting: string;
|
||||
attachReady: string;
|
||||
attachError: string;
|
||||
attachClearLabel: string;
|
||||
attachContextBadge: string;
|
||||
attachAccept: string;
|
||||
attachTruncated: string;
|
||||
attachErrorMsg: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -480,5 +510,35 @@ export const en: Translations = {
|
||||
citationsHeader: 'Sources',
|
||||
citationsEmpty: 'Citations will appear here after a response is generated.',
|
||||
apiError: 'Could not reach the RAG API. Please check the backend.',
|
||||
// ── Agentic mode ─────────────────────────────────────────────────────────
|
||||
agenticMode: 'Agentic mode',
|
||||
agenticModeHint: 'Intent · Planning · Retrieval · Grounding',
|
||||
agentThinking: 'Agent reasoning…',
|
||||
agentDone: 'Reasoning complete',
|
||||
stepSuffix: 'steps',
|
||||
stepIntentAnalysis: 'Intent analysis',
|
||||
stepQueryPlanning: 'Query planning',
|
||||
stepRetrieving: 'Knowledge retrieval',
|
||||
stepGrounding: 'Citation grounding',
|
||||
intentSimpleQa: 'Simple Q&A',
|
||||
intentCompare: 'Comparison',
|
||||
intentMultiHop: 'Multi-hop',
|
||||
intentAmbiguous: 'Ambiguous',
|
||||
intentNeedsDecomposition: 'Decomposed',
|
||||
subQueriesCountSuffix: 'sub-queries',
|
||||
chunksFoundSuffix: 'chunks',
|
||||
retryLabel: '(retry) ',
|
||||
groundingSufficient: '✓ Sufficient',
|
||||
groundingInsufficient: '⚠ Re-queried',
|
||||
// ── Document context attachment ───────────────────────────────────────────
|
||||
attachBtn: 'Attach document as context',
|
||||
attachExtracting: 'Extracting text…',
|
||||
attachReady: 'Context loaded',
|
||||
attachError: 'Extraction failed',
|
||||
attachClearLabel: 'Clear',
|
||||
attachContextBadge: 'Doc context',
|
||||
attachAccept: '.pdf,.docx,.doc,.txt,.md',
|
||||
attachTruncated: '(truncated to 8 000 chars)',
|
||||
attachErrorMsg: 'Could not extract text from this file.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -239,5 +239,35 @@ export const zh: Translations = {
|
||||
citationsHeader: '引用来源',
|
||||
citationsEmpty: '生成回答后,引用来源将显示在此处。',
|
||||
apiError: '无法连接到 RAG API,请检查后端服务。',
|
||||
// ── Agentic mode ─────────────────────────────────────────────────────────
|
||||
agenticMode: 'Agentic 模式',
|
||||
agenticModeHint: '意图分析 · 查询分解 · 迭代检索 · 引文锚定',
|
||||
agentThinking: 'Agent 推理中…',
|
||||
agentDone: '推理完成',
|
||||
stepSuffix: '步',
|
||||
stepIntentAnalysis: '意图分析',
|
||||
stepQueryPlanning: '查询分解',
|
||||
stepRetrieving: '知识检索',
|
||||
stepGrounding: '引文锚定',
|
||||
intentSimpleQa: '单跳问答',
|
||||
intentCompare: '对比分析',
|
||||
intentMultiHop: '多跳推理',
|
||||
intentAmbiguous: '模糊查询',
|
||||
intentNeedsDecomposition: '需分解',
|
||||
subQueriesCountSuffix: '个子查询',
|
||||
chunksFoundSuffix: '条',
|
||||
retryLabel: '(补充) ',
|
||||
groundingSufficient: '✓ 充分',
|
||||
groundingInsufficient: '⚠ 补充检索',
|
||||
// ── Document context attachment ───────────────────────────────────────────
|
||||
attachBtn: '上传文档作为对话上下文',
|
||||
attachExtracting: '正在提取文本…',
|
||||
attachReady: '上下文已加载',
|
||||
attachError: '提取失败',
|
||||
attachClearLabel: '清除',
|
||||
attachContextBadge: '文档上下文',
|
||||
attachAccept: '.pdf,.docx,.doc,.txt,.md',
|
||||
attachTruncated: '(已截断至 8000 字符)',
|
||||
attachErrorMsg: '无法从该文件提取文本,请检查文件格式。',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Search, Plus, AlertTriangle, Download, MessageSquare, ChevronDown } from 'lucide-react';
|
||||
import { Search, Plus, Download, MessageSquare, ChevronDown, AlertTriangle } from 'lucide-react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { NewAnalysisModal } from './NewAnalysisModal';
|
||||
import { useComplianceAnalysis } from './useComplianceAnalysis';
|
||||
@@ -39,81 +39,8 @@ function formatTs(iso: string) {
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
// ── Chat state for a single finding ─────────────────────────────────────────
|
||||
interface ChatMsg { id: number; role: 'user' | 'assistant'; content: string }
|
||||
|
||||
function useFindingChat() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [findingIdx, setFindingIdx] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
function openFor(idx: number, finding: FindingEvent) {
|
||||
setFindingIdx(idx);
|
||||
setOpen(true);
|
||||
setMessages([{
|
||||
id: 0,
|
||||
role: 'assistant',
|
||||
content: `I'm reviewing finding: **${finding.title}**\n\n${finding.desc}${finding.clause_ref ? `\n\nRef: ${finding.clause_ref}` : ''}\n\nHow can I help?`,
|
||||
}]);
|
||||
setInput('');
|
||||
}
|
||||
|
||||
function close() { setOpen(false); abortRef.current?.abort(); }
|
||||
|
||||
async function send(segmentContext: string) {
|
||||
if (!input.trim() || loading) return;
|
||||
const q = input.trim();
|
||||
setInput('');
|
||||
const userMsg: ChatMsg = { id: Date.now(), role: 'user', content: q };
|
||||
const assistantId = Date.now() + 1;
|
||||
setMessages(m => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
|
||||
setLoading(true);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/compliance/chat/${findingIdx ?? 0}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ query: q, segment_context: segmentContext }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.body) { setLoading(false); return; }
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
const blocks = buf.split('\n\n');
|
||||
buf = blocks.pop() ?? '';
|
||||
for (const block of blocks) {
|
||||
const dl = block.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dl) continue;
|
||||
try {
|
||||
const j = JSON.parse(dl.slice(6));
|
||||
if (j.type === 'chunk' && j.text) {
|
||||
setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: msg.content + j.text } : msg));
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.name === 'AbortError') return;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { open, findingIdx, messages, input, setInput, loading, openFor, close, send };
|
||||
}
|
||||
|
||||
function _FindingChatDrawerWrapper({
|
||||
/** Wrapper that resolves findingIndex → findingId from the saved analysis, then renders FindingChatDrawer. */
|
||||
function FindingChatDrawerWrapper({
|
||||
analysisId,
|
||||
findingIndex,
|
||||
finding,
|
||||
@@ -128,7 +55,7 @@ function _FindingChatDrawerWrapper({
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/compliance/history/${analysisId}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then((data: { findings?: Array<{ seq: number; id: string }> }) => {
|
||||
@@ -153,8 +80,8 @@ export function CompliancePage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const { state, run, reset } = useComplianceAnalysis();
|
||||
const chat = useFindingChat();
|
||||
const [drawerFindingIdx, setDrawerFindingIdx] = useState<number | null>(null);
|
||||
// drawerFinding holds {index, finding} for the currently-open FindingChatDrawer
|
||||
const [drawerFinding, setDrawerFinding] = useState<{ idx: number; finding: FindingEvent } | null>(null);
|
||||
|
||||
const { setComplianceState } = usePageState();
|
||||
const { t } = useLanguage();
|
||||
@@ -198,6 +125,8 @@ export function CompliancePage() {
|
||||
analysisId: data.id,
|
||||
isReadOnly: true,
|
||||
activeFindingId: null,
|
||||
progress: null,
|
||||
conflicts: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,12 +187,6 @@ export function CompliancePage() {
|
||||
setShowExportMenu(false);
|
||||
}
|
||||
|
||||
// ── Chat context (finding desc + clause_ref as segment context) ──────────
|
||||
const activeFinding = chat.findingIdx !== null ? state.findings[chat.findingIdx] : null;
|
||||
const chatContext = activeFinding
|
||||
? `Finding: ${activeFinding.title}\n${activeFinding.desc}${activeFinding.clause_ref ? `\nRef: ${activeFinding.clause_ref}` : ''}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="compliance-page" style={{ position: 'relative' }}>
|
||||
<Topbar
|
||||
@@ -457,6 +380,25 @@ export function CompliancePage() {
|
||||
<div className="comp-col findings-col">
|
||||
<div className="col-header">
|
||||
Findings {state.findings.length > 0 && `(${state.findings.length})`}
|
||||
{/* Real per-clause progress bar during streaming */}
|
||||
{isStreaming && state.progress && state.progress.total > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 8, fontSize: 10, color: 'var(--muted)',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 60, height: 4,
|
||||
background: 'var(--border)', borderRadius: 2, overflow: 'hidden',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'block', height: '100%',
|
||||
width: `${Math.round((state.progress.done / state.progress.total) * 100)}%`,
|
||||
background: 'var(--accent)', transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</span>
|
||||
{state.progress.done}/{state.progress.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.findings.length === 0 && isStreaming && (
|
||||
@@ -472,30 +414,85 @@ export function CompliancePage() {
|
||||
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
|
||||
</div>
|
||||
<p className="finding-desc">{f.desc}</p>
|
||||
|
||||
{/* Source refs: which retrieved chunks informed this finding */}
|
||||
{f.source_refs && f.source_refs.length > 0 && (
|
||||
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{f.source_refs.map((sr, si) => (
|
||||
<span key={si} style={{
|
||||
fontSize: 10, padding: '1px 6px',
|
||||
background: 'var(--bg)', border: '1px solid var(--border)',
|
||||
borderRadius: 4, color: 'var(--muted)',
|
||||
}} title={sr.clause}>
|
||||
📄 {sr.standard ? sr.standard.slice(0, 20) : '—'}
|
||||
{sr.score > 0 && ` · ${Math.round(sr.score * 100)}%`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
|
||||
{f.clause_ref && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
|
||||
)}
|
||||
<button
|
||||
className="btn sm"
|
||||
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
|
||||
onClick={() => chat.openFor(i, f)}
|
||||
>
|
||||
<MessageSquare size={11} />{t.compliance.askAIBtn}
|
||||
</button>
|
||||
{state.analysisId && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{f.clause_ref && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
|
||||
)}
|
||||
{/* Confidence dot: green ≥0.7, amber 0.4–0.7, red <0.4 */}
|
||||
{f.confidence !== undefined && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10, color: 'var(--muted)',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
}}
|
||||
title={`Retrieval confidence: ${Math.round(f.confidence * 100)}%`}
|
||||
>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: f.confidence >= 0.7 ? '#22c55e' : f.confidence >= 0.4 ? '#f59e0b' : '#ef4444',
|
||||
}} />
|
||||
{Math.round(f.confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Single consolidated chat button — only when analysis is saved */}
|
||||
{state.analysisId ? (
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={() => setDrawerFindingIdx(i)}
|
||||
style={{ marginTop: 6 }}
|
||||
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
|
||||
onClick={() => setDrawerFinding({ idx: i, finding: f })}
|
||||
>
|
||||
💬 {t.compliance.chatBtn}
|
||||
<MessageSquare size={11} />{t.compliance.chatBtn}
|
||||
</button>
|
||||
) : (
|
||||
/* Fallback for unsaved analyses: show disabled chat hint */
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)' }}>
|
||||
{t.compliance.askAIBtn}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Cross-clause conflicts panel */}
|
||||
{state.conflicts && state.conflicts.length > 0 && (
|
||||
<div className="card" style={{ borderLeft: '3px solid #f59e0b', marginTop: 8 }}>
|
||||
<div className="card-header" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<AlertTriangle size={12} color="#f59e0b" />
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>Cross-Clause Issues ({state.conflicts.length})</span>
|
||||
</div>
|
||||
{state.conflicts.map((c, ci) => (
|
||||
<div key={ci} style={{ fontSize: 11, color: 'var(--muted)', padding: '4px 0', borderTop: ci ? '1px solid var(--border)' : 'none' }}>
|
||||
<span style={{
|
||||
fontWeight: 600,
|
||||
color: c.type === 'contradiction' ? '#ef4444' : c.type === 'cumulative_risk' ? '#f59e0b' : 'var(--fg)',
|
||||
}}>
|
||||
[{c.type.replace('_', ' ')}]
|
||||
</span>
|
||||
{' '}Finding #{c.finding_a}{c.finding_b ? ` ↔ #${c.finding_b}` : ''}: {c.desc}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conclusion */}
|
||||
{isDone && state.done && (
|
||||
<div className="card conclusion-box">
|
||||
@@ -540,92 +537,18 @@ export function CompliancePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Finding Chat Side Panel ────────────────────────────────── */}
|
||||
{chat.open && (
|
||||
<div style={{
|
||||
position: 'fixed', right: 0, top: 0, bottom: 0, width: 400,
|
||||
background: 'var(--surface)', borderLeft: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column', zIndex: 200,
|
||||
boxShadow: '-8px 0 32px rgba(0,0,0,.12)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.compliance.chatSidebarHeader}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Finding #{(chat.findingIdx ?? 0) + 1} · {activeFinding?.title}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={chat.close}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', padding: 4 }}
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{chat.messages.map(msg => (
|
||||
<div key={msg.id} style={{ display: 'flex', gap: 10, flexDirection: msg.role === 'user' ? 'row-reverse' : 'row' }}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
|
||||
)}
|
||||
<div style={{
|
||||
maxWidth: '82%', padding: '10px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap',
|
||||
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg)',
|
||||
color: msg.role === 'user' ? '#fff' : 'var(--fg)',
|
||||
border: msg.role === 'assistant' ? '1px solid var(--border)' : 'none',
|
||||
}}>{msg.content}</div>
|
||||
</div>
|
||||
))}
|
||||
{chat.loading && (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
|
||||
<div style={{ padding: '10px 14px', borderRadius: 10, border: '1px solid var(--border)', background: 'var(--bg)', fontSize: 13, color: 'var(--muted)' }}>
|
||||
{t.compliance.chatThinking}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick questions */}
|
||||
<div style={{ padding: '8px 20px', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{[t.compliance.quickQ1, t.compliance.quickQ2, t.compliance.quickQ3].map(q => (
|
||||
<button key={q} onClick={() => chat.setInput(q)}
|
||||
style={{ padding: '4px 10px', fontSize: 11, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--muted)' }}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border)', display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={chat.input}
|
||||
onChange={e => chat.setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chat.send(chatContext); } }}
|
||||
placeholder={t.compliance.chatPlaceholder}
|
||||
style={{ flex: 1, padding: '9px 12px', fontSize: 13, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--fg)', outline: 'none' }}
|
||||
/>
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => chat.send(chatContext)}
|
||||
disabled={!chat.input.trim() || chat.loading}
|
||||
style={{ padding: '9px 14px' }}
|
||||
>{t.compliance.sendBtn}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{drawerFindingIdx !== null && state.analysisId && (
|
||||
<_FindingChatDrawerWrapper
|
||||
{/* ── Finding Chat Drawer (single consolidated UI) ───────────── */}
|
||||
{drawerFinding !== null && state.analysisId && (
|
||||
<FindingChatDrawerWrapper
|
||||
analysisId={state.analysisId}
|
||||
findingIndex={drawerFindingIdx}
|
||||
findingIndex={drawerFinding.idx}
|
||||
finding={{
|
||||
title: state.findings[drawerFindingIdx]?.title ?? '',
|
||||
desc: state.findings[drawerFindingIdx]?.desc ?? '',
|
||||
status: state.findings[drawerFindingIdx]?.status ?? 'ok',
|
||||
clause_ref: state.findings[drawerFindingIdx]?.clause_ref,
|
||||
title: drawerFinding.finding.title,
|
||||
desc: drawerFinding.finding.desc,
|
||||
status: drawerFinding.finding.status,
|
||||
clause_ref: drawerFinding.finding.clause_ref,
|
||||
}}
|
||||
onClose={() => setDrawerFindingIdx(null)}
|
||||
onClose={() => setDrawerFinding(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -14,9 +14,10 @@ import type {
|
||||
ComplianceSourceEvent,
|
||||
ComplianceFindingEvent,
|
||||
ComplianceDonePayload,
|
||||
ComplianceConflict,
|
||||
} from '../../contexts';
|
||||
|
||||
export type { ComplianceMeta, ComplianceState, ComplianceSourceEvent as SourceEvent, ComplianceFindingEvent as FindingEvent, ComplianceDonePayload as DonePayload };
|
||||
export type { ComplianceMeta, ComplianceState, ComplianceSourceEvent as SourceEvent, ComplianceFindingEvent as FindingEvent, ComplianceDonePayload as DonePayload, ComplianceConflict };
|
||||
export type { ComplianceActionItem as ActionItem } from '../../contexts';
|
||||
export type AnalysisStatus = import('../../contexts').ComplianceStatus;
|
||||
export type AnalysisMeta = ComplianceMeta;
|
||||
@@ -38,6 +39,8 @@ const INITIAL_STATE: ComplianceState = {
|
||||
errorText: '',
|
||||
analysisId: null,
|
||||
isReadOnly: false,
|
||||
progress: null,
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
export function useComplianceAnalysis() {
|
||||
@@ -92,6 +95,9 @@ export function useComplianceAnalysis() {
|
||||
|
||||
if (j.type === 'stage') {
|
||||
setState(s => ({ ...s, stageLabel: j.label ?? '', stageKey: j.stage ?? '' }));
|
||||
} else if (j.type === 'progress') {
|
||||
// Real per-clause progress update from backend
|
||||
setState(s => ({ ...s, progress: { done: j.done ?? 0, total: j.total ?? 0 } }));
|
||||
} else if (j.type === 'source') {
|
||||
const src: ComplianceSourceEvent = {
|
||||
standard: j.standard ?? '',
|
||||
@@ -99,6 +105,7 @@ export function useComplianceAnalysis() {
|
||||
score: j.score ?? 0,
|
||||
status: j.status ?? 'retrieved',
|
||||
full_content: j.full_content ?? '',
|
||||
clause_index: j.clause_index,
|
||||
};
|
||||
setState(s => ({ ...s, sources: [...s.sources, src] }));
|
||||
} else if (j.type === 'finding') {
|
||||
@@ -107,8 +114,13 @@ export function useComplianceAnalysis() {
|
||||
desc: j.desc ?? '',
|
||||
status: j.status ?? 'info',
|
||||
clause_ref: j.clause_ref,
|
||||
confidence: j.confidence,
|
||||
source_refs: j.source_refs,
|
||||
};
|
||||
setState(s => ({ ...s, findings: [...s.findings, finding] }));
|
||||
} else if (j.type === 'conflicts') {
|
||||
// Cross-clause conflicts detected after all findings finish
|
||||
setState(s => ({ ...s, conflicts: j.items ?? [] }));
|
||||
} else if (j.type === 'done') {
|
||||
const payload: ComplianceDonePayload = {
|
||||
conclusion: j.conclusion ?? '',
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Doc {
|
||||
sizeBytes: number;
|
||||
summary?: string;
|
||||
version?: string;
|
||||
hasFile: boolean;
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
|
||||
@@ -102,6 +103,7 @@ export function DocsPage() {
|
||||
sizeBytes: (item.size_bytes as number) ?? 0,
|
||||
summary: item.summary as string | undefined,
|
||||
version: item.version as string | undefined,
|
||||
hasFile: item.has_file !== false,
|
||||
})));
|
||||
setLoading(false);
|
||||
})
|
||||
@@ -130,11 +132,21 @@ export function DocsPage() {
|
||||
}
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────────────────
|
||||
function downloadDoc(id: string, name: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/v1/documents/download/${id}`;
|
||||
a.download = name;
|
||||
a.click();
|
||||
async function downloadDoc(id: string, name: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/documents/download/${id}`, { headers: authHeader() });
|
||||
if (!resp.ok) throw new Error(`下载失败: ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Download failed', err);
|
||||
alert(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retry (re-process failed doc) ────────────────────────────────────────
|
||||
@@ -289,11 +301,13 @@ export function DocsPage() {
|
||||
<span className="cell-mono">{formatSize(d.sizeBytes)}</span>
|
||||
<span className="cell-muted">{d.type}</span>
|
||||
<span className="row-actions">
|
||||
{/* Download */}
|
||||
{/* Download — disabled for Milvus-only docs that have no binary file */}
|
||||
<button
|
||||
className="text-link"
|
||||
title={t.docs.titleDownload}
|
||||
title={d.hasFile ? t.docs.titleDownload : '无原始文件'}
|
||||
onClick={() => downloadDoc(d.id, d.name)}
|
||||
disabled={!d.hasFile}
|
||||
style={!d.hasFile ? { opacity: 0.3, cursor: 'not-allowed' } : undefined}
|
||||
>
|
||||
<Download size={12} />
|
||||
</button>
|
||||
|
||||
@@ -222,7 +222,7 @@ export function UploadModal({ onClose, onComplete }: Props) {
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close" disabled={submitting}><X size={14} /></button>
|
||||
|
||||
{/* ── Left panel: upload form ── */}
|
||||
<div className="modal-panel">
|
||||
<div className="modal-panel" style={{ overflowY: 'auto' }}>
|
||||
<div className="modal-eyebrow">Upload documents</div>
|
||||
<div className="modal-title">Stage files for parsing and indexing.</div>
|
||||
<p className="modal-lead">PDF, DOCX, TXT — one per API call, processed sequentially.</p>
|
||||
@@ -254,7 +254,7 @@ export function UploadModal({ onClose, onComplete }: Props) {
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="staged-files">
|
||||
<div className="staged-files" style={{ maxHeight: 220, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{files.map((f, i) => {
|
||||
const isDone = doneCount > i;
|
||||
const isActive = submitting && currentFileIdx === i;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Send, Download } from 'lucide-react';
|
||||
import { Send, Download, Zap, Paperclip, X, FileText, AlertCircle } from 'lucide-react';
|
||||
import { usePageState } from '../../contexts';
|
||||
import type { RagCitation } from '../../contexts';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { agenticChat } from '../../api/rag';
|
||||
import type { SSEMessage } from '../../api/index';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -11,6 +13,46 @@ function authHeader(): Record<string, string> {
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
// ── Document context state ─────────────────────────────────────────────────────
|
||||
|
||||
interface DocContext {
|
||||
filename: string;
|
||||
text: string;
|
||||
charCount: number;
|
||||
truncated: boolean;
|
||||
/** 'extracting' while the backend is parsing; 'ready' when text is available; 'error' on failure */
|
||||
status: 'extracting' | 'ready' | 'error';
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
// ── Agentic-mode types ────────────────────────────────────────────────────────
|
||||
|
||||
interface ThinkingStep {
|
||||
id: string;
|
||||
step: string;
|
||||
status: 'running' | 'done';
|
||||
intent_type?: string;
|
||||
reason?: string;
|
||||
requires_decomposition?: boolean;
|
||||
sub_queries?: string[];
|
||||
query?: string;
|
||||
index?: number;
|
||||
total?: number;
|
||||
found?: number;
|
||||
sufficient?: boolean;
|
||||
confidence?: number;
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
const STEP_ICONS: Record<string, string> = {
|
||||
intent_analysis: '🔍',
|
||||
query_planning: '📋',
|
||||
retrieving: '📚',
|
||||
grounding_check: '🔗',
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
|
||||
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
|
||||
const rawScore = typeof s.score === 'number' ? s.score : 0;
|
||||
@@ -69,10 +111,72 @@ export function RagChatPage() {
|
||||
const [streaming, setStreaming] = useState(ragStreamingRef.current);
|
||||
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
|
||||
|
||||
// P0-1 Agentic mode state
|
||||
const [agenticMode, setAgenticMode] = useState(false);
|
||||
const [thinkingSteps, setThinkingSteps] = useState<ThinkingStep[]>([]);
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(true);
|
||||
|
||||
// ── Document context state ─────────────────────────────────────────────────
|
||||
// Holds the extracted text from the attached file; sent to the backend as
|
||||
// conversation context on every message while it is set.
|
||||
const [docContext, setDocContext] = useState<DocContext | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const citRailRef = useRef<HTMLDivElement>(null);
|
||||
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
|
||||
// ── Document context helpers ───────────────────────────────────────────────
|
||||
|
||||
/** Upload file to /rag/upload-context, extract its text, store as context. */
|
||||
async function handleFileAttach(file: File) {
|
||||
setDocContext({ filename: file.name, text: '', charCount: 0, truncated: false, status: 'extracting' });
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/rag/upload-context', {
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => t.ragchat.attachErrorMsg);
|
||||
setDocContext(prev => prev ? { ...prev, status: 'error', errorMsg: errText.slice(0, 120) } : null);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setDocContext({
|
||||
filename: data.filename ?? file.name,
|
||||
text: data.text ?? '',
|
||||
charCount: data.char_count ?? 0,
|
||||
truncated: data.truncated ?? false,
|
||||
status: 'ready',
|
||||
});
|
||||
} catch (err) {
|
||||
setDocContext(prev => prev
|
||||
? { ...prev, status: 'error', errorMsg: String(err).slice(0, 120) }
|
||||
: null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFileAttach(file);
|
||||
// Reset so the same file can be re-selected
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
function handleFileDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
const file = Array.from(e.dataTransfer.files).find(f =>
|
||||
/\.(pdf|docx?|txt|md)$/i.test(f.name)
|
||||
);
|
||||
if (file) void handleFileAttach(file);
|
||||
}
|
||||
|
||||
// Fetch quick questions from backend on mount (only once per session)
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
|
||||
@@ -102,9 +206,17 @@ export function RagChatPage() {
|
||||
|
||||
async function send(text?: string) {
|
||||
const q = (text ?? inputDraft).trim();
|
||||
if (!q || ragStreamingRef.current) return;
|
||||
// Block send while a document is still being extracted
|
||||
if (!q || ragStreamingRef.current || docContext?.status === 'extracting') return;
|
||||
|
||||
setRagState(s => ({ ...s, inputDraft: '' }));
|
||||
|
||||
// Show document context badge in user message bubble when active
|
||||
const docPrefix = docContext?.status === 'ready'
|
||||
? `📄 ${docContext.filename}\n`
|
||||
: '';
|
||||
const displayQuery = docPrefix + q;
|
||||
|
||||
const userMsgId = Date.now().toString();
|
||||
const assistantId = (Date.now() + 1).toString();
|
||||
|
||||
@@ -112,7 +224,7 @@ export function RagChatPage() {
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{ id: userMsgId, role: 'user', text: q },
|
||||
{ id: userMsgId, role: 'user', text: displayQuery },
|
||||
{ id: assistantId, role: 'assistant', text: '' },
|
||||
],
|
||||
citations: [],
|
||||
@@ -122,100 +234,211 @@ export function RagChatPage() {
|
||||
setStreaming(true);
|
||||
setHighlightedCit(null);
|
||||
|
||||
// P0-1: reset thinking panel for new query
|
||||
if (agenticMode) {
|
||||
setThinkingSteps([]);
|
||||
setThinkingExpanded(true);
|
||||
}
|
||||
|
||||
const ctrl = new AbortController();
|
||||
ragAbortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = { query: q, top_k: 5 };
|
||||
if (sessionId) body.session_id = sessionId;
|
||||
|
||||
const res = await fetch('/api/v1/rag/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error('No stream');
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buffer = '';
|
||||
if (agenticMode) {
|
||||
// ── Agentic path ────────────────────────────────────────────────────
|
||||
const newCitations: RagCitation[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += dec.decode(value, { stream: true });
|
||||
const handleMessage = (msg: SSEMessage) => {
|
||||
if (msg.type === 'session') {
|
||||
if (msg.session_id) setRagState(s => ({ ...s, sessionId: msg.session_id! }));
|
||||
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
|
||||
for (const block of blocks) {
|
||||
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
const raw = dataLine.slice(6).trim();
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const j = JSON.parse(raw);
|
||||
|
||||
if (j.type === 'session') {
|
||||
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
|
||||
|
||||
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
|
||||
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
|
||||
newCitations.push(...mapped);
|
||||
setRagState(s => ({ ...s, citations: [...mapped] }));
|
||||
|
||||
} else if (j.type === 'chunk' && j.text) {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: msg.text + (j.text as string) }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
|
||||
} else if (j.type === 'done') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg => {
|
||||
if (msg.id !== assistantId) return msg;
|
||||
const refs = [...new Set(
|
||||
[...msg.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
|
||||
)].filter(n => n >= 1 && n <= newCitations.length);
|
||||
return { ...msg, citationRefs: refs };
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
|
||||
} else if (j.type === 'error') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
} else if (msg.type === 'thinking') {
|
||||
// Build a stable step id so we can upsert running→done transitions.
|
||||
const stepId = `${msg.step}-${msg.retry ? 'retry' : (msg.index ?? 0)}`;
|
||||
setThinkingSteps(prev => {
|
||||
const idx = prev.findIndex(s => s.id === stepId);
|
||||
const stepObj: ThinkingStep = {
|
||||
id: stepId,
|
||||
step: msg.step ?? '',
|
||||
status: (msg.status as 'running' | 'done') ?? 'running',
|
||||
intent_type: msg.intent_type,
|
||||
reason: msg.reason,
|
||||
sub_queries: msg.sub_queries,
|
||||
query: msg.query,
|
||||
index: msg.index,
|
||||
total: msg.total,
|
||||
found: msg.found,
|
||||
sufficient: msg.sufficient,
|
||||
confidence: msg.confidence,
|
||||
retry: msg.retry,
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = stepObj;
|
||||
return updated;
|
||||
}
|
||||
} catch { /* malformed JSON chunk, skip */ }
|
||||
return [...prev, stepObj];
|
||||
});
|
||||
|
||||
} else if (msg.type === 'retrieved' && Array.isArray(msg.docs)) {
|
||||
const mapped = (msg.docs as unknown as Record<string, unknown>[]).map((d, i) => mapSource(d, i + 1));
|
||||
newCitations.push(...mapped);
|
||||
setRagState(s => ({ ...s, citations: [...mapped] }));
|
||||
|
||||
} else if (msg.type === 'chunk' && msg.text) {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(m =>
|
||||
m.id === assistantId ? { ...m, text: m.text + msg.text! } : m
|
||||
),
|
||||
}));
|
||||
|
||||
} else if (msg.type === 'done') {
|
||||
setThinkingExpanded(false);
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(m => {
|
||||
if (m.id !== assistantId) return m;
|
||||
const refs = [...new Set(
|
||||
[...m.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
|
||||
)].filter(n => n >= 1 && n <= newCitations.length);
|
||||
return { ...m, citationRefs: refs };
|
||||
}),
|
||||
}));
|
||||
|
||||
} else if (msg.type === 'error') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(m =>
|
||||
m.id === assistantId ? { ...m, text: `Error: ${msg.text ?? 'Unknown error'}` } : m
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await agenticChat(
|
||||
q, 5, handleMessage,
|
||||
(err) => {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(m =>
|
||||
m.id === assistantId ? { ...m, text: t.ragchat.apiError } : m
|
||||
),
|
||||
}));
|
||||
console.error('agenticChat error:', err);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
sessionId ?? undefined,
|
||||
ctrl.signal,
|
||||
// Pass document context to agentic pipeline
|
||||
docContext?.status === 'ready' ? docContext.text : undefined,
|
||||
docContext?.status === 'ready' ? docContext.filename : undefined,
|
||||
);
|
||||
} finally {
|
||||
ragStreamingRef.current = false;
|
||||
setStreaming(false);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.name !== 'AbortError') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: t.ragchat.apiError }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
|
||||
} else {
|
||||
// ── Standard RAG path (unchanged) ───────────────────────────────────
|
||||
try {
|
||||
const body: Record<string, unknown> = { query: q, top_k: 5 };
|
||||
if (sessionId) body.session_id = sessionId;
|
||||
// Inject document text as conversation context when a file is attached
|
||||
if (docContext?.status === 'ready') {
|
||||
body.context_text = docContext.text;
|
||||
body.context_filename = docContext.filename;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/v1/rag/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error('No stream');
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buffer = '';
|
||||
const newCitations: RagCitation[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += dec.decode(value, { stream: true });
|
||||
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
|
||||
for (const block of blocks) {
|
||||
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!dataLine) continue;
|
||||
const raw = dataLine.slice(6).trim();
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const j = JSON.parse(raw);
|
||||
|
||||
if (j.type === 'session') {
|
||||
if (j.session_id) setRagState(s => ({ ...s, sessionId: j.session_id }));
|
||||
|
||||
} else if (j.type === 'retrieved' && Array.isArray(j.docs)) {
|
||||
const mapped = j.docs.map((d: Record<string, unknown>, i: number) => mapSource(d, i + 1));
|
||||
newCitations.push(...mapped);
|
||||
setRagState(s => ({ ...s, citations: [...mapped] }));
|
||||
|
||||
} else if (j.type === 'chunk' && j.text) {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: msg.text + (j.text as string) }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
|
||||
} else if (j.type === 'done') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg => {
|
||||
if (msg.id !== assistantId) return msg;
|
||||
const refs = [...new Set(
|
||||
[...msg.text.matchAll(/\[(\d+)\]/g)].map(r => parseInt(r[1], 10))
|
||||
)].filter(n => n >= 1 && n <= newCitations.length);
|
||||
return { ...msg, citationRefs: refs };
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
|
||||
} else if (j.type === 'error') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: `Error: ${j.text ?? 'Unknown error'}` }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
}
|
||||
} catch { /* malformed JSON chunk, skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.name !== 'AbortError') {
|
||||
setRagState(s => ({
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: t.ragchat.apiError }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
ragStreamingRef.current = false;
|
||||
setStreaming(false);
|
||||
}
|
||||
} finally {
|
||||
ragStreamingRef.current = false;
|
||||
setStreaming(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +477,126 @@ export function RagChatPage() {
|
||||
|
||||
{/* ── Chat main ── */}
|
||||
<div className="chat-main">
|
||||
<div className="messages">
|
||||
{/* P0-1: Agentic Thinking Panel — shown when agentic mode is active */}
|
||||
{agenticMode && thinkingSteps.length > 0 && (
|
||||
<div style={{
|
||||
margin: '0 0 4px 0',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
background: streaming ? 'var(--surface)' : 'var(--surface-2, var(--surface))',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 0.4s ease',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{/* Panel header — clickable to collapse/expand */}
|
||||
<button
|
||||
onClick={() => setThinkingExpanded(x => !x)}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: streaming ? 'var(--accent, #6366f1)' : 'var(--success-fg, #16a34a)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>{streaming ? '⚙' : '✓'}</span>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{streaming
|
||||
? t.ragchat.agentThinking
|
||||
: `${t.ragchat.agentDone} · ${thinkingSteps.filter(s => s.status === 'done').length} ${t.ragchat.stepSuffix}`
|
||||
}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10 }}>{thinkingExpanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{/* Step list */}
|
||||
{thinkingExpanded && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
{thinkingSteps.map(step => {
|
||||
const stepLabels: Record<string, string> = {
|
||||
intent_analysis: t.ragchat.stepIntentAnalysis,
|
||||
query_planning: t.ragchat.stepQueryPlanning,
|
||||
retrieving: t.ragchat.stepRetrieving,
|
||||
grounding_check: t.ragchat.stepGrounding,
|
||||
};
|
||||
const intentLabels: Record<string, string> = {
|
||||
simple_qa: t.ragchat.intentSimpleQa,
|
||||
compare: t.ragchat.intentCompare,
|
||||
multi_hop: t.ragchat.intentMultiHop,
|
||||
ambiguous: t.ragchat.intentAmbiguous,
|
||||
};
|
||||
return (
|
||||
<div key={step.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
padding: '3px 0',
|
||||
color: step.status === 'done' ? 'var(--fg)' : 'var(--muted)',
|
||||
}}>
|
||||
<span style={{ width: 16, textAlign: 'center', flexShrink: 0 }}>
|
||||
{step.status === 'running'
|
||||
? <span style={{ animation: 'spin 1s linear infinite', display: 'inline-block' }}>⟳</span>
|
||||
: (STEP_ICONS[step.step] ?? '·')
|
||||
}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{stepLabels[step.step] ?? step.step}</strong>
|
||||
{/* Intent analysis detail */}
|
||||
{step.step === 'intent_analysis' && step.status === 'done' && step.intent_type && (
|
||||
<span style={{ marginLeft: 6, color: 'var(--muted)' }}>
|
||||
→ {intentLabels[step.intent_type] ?? step.intent_type}
|
||||
{step.requires_decomposition && ` · ${t.ragchat.intentNeedsDecomposition}`}
|
||||
</span>
|
||||
)}
|
||||
{/* Query planning detail */}
|
||||
{step.step === 'query_planning' && step.status === 'done' && step.sub_queries && (
|
||||
<span style={{ marginLeft: 6, color: 'var(--muted)' }}>
|
||||
→ {step.sub_queries.length} {t.ragchat.subQueriesCountSuffix}
|
||||
</span>
|
||||
)}
|
||||
{/* Retrieval detail */}
|
||||
{step.step === 'retrieving' && (
|
||||
<span style={{ marginLeft: 6, color: 'var(--muted)', wordBreak: 'break-all' }}>
|
||||
{step.total && step.total > 1 && `[${step.index}/${step.total}] `}
|
||||
{step.retry && t.ragchat.retryLabel}
|
||||
{step.query && step.query.length > 50
|
||||
? step.query.slice(0, 50) + '…'
|
||||
: step.query}
|
||||
{step.status === 'done' && step.found !== undefined && (
|
||||
<span style={{ color: step.found > 0 ? 'var(--success-fg, #16a34a)' : 'var(--warning, #ca8a04)' }}>
|
||||
{' '}· {step.found} {t.ragchat.chunksFoundSuffix}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Grounding check detail */}
|
||||
{step.step === 'grounding_check' && step.status === 'done' && (
|
||||
<span style={{ marginLeft: 6, color: step.sufficient ? 'var(--success-fg, #16a34a)' : 'var(--warning, #ca8a04)' }}>
|
||||
→ {step.sufficient ? t.ragchat.groundingSufficient : t.ragchat.groundingInsufficient}
|
||||
{step.confidence !== undefined && ` (${Math.round(step.confidence * 100)}%)`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages area — accepts drag-and-drop document context attachment */}
|
||||
<div
|
||||
className="messages"
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`message msg-${msg.role}`}>
|
||||
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
|
||||
@@ -281,19 +623,130 @@ export function RagChatPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* P0-1: Agentic mode toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
fontSize: 12, color: agenticMode ? 'var(--accent, #6366f1)' : 'var(--muted)',
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agenticMode}
|
||||
onChange={e => {
|
||||
setAgenticMode(e.target.checked);
|
||||
setThinkingSteps([]);
|
||||
}}
|
||||
style={{ cursor: 'pointer', accentColor: 'var(--accent, #6366f1)' }}
|
||||
/>
|
||||
<Zap size={11} />
|
||||
<span>{t.ragchat.agenticMode}</span>
|
||||
</label>
|
||||
{agenticMode && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
|
||||
{t.ragchat.agenticModeHint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Document context badge ── */}
|
||||
{docContext && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', marginBottom: 6,
|
||||
background: docContext.status === 'error'
|
||||
? 'rgba(220,38,38,0.06)'
|
||||
: docContext.status === 'ready'
|
||||
? 'rgba(34,197,94,0.06)'
|
||||
: 'rgba(99,102,241,0.06)',
|
||||
border: `1px solid ${
|
||||
docContext.status === 'error' ? 'rgba(220,38,38,0.3)'
|
||||
: docContext.status === 'ready' ? 'rgba(34,197,94,0.3)'
|
||||
: 'rgba(99,102,241,0.3)'
|
||||
}`,
|
||||
borderRadius: 8, fontSize: 12,
|
||||
}}>
|
||||
{docContext.status === 'extracting' && (
|
||||
<span style={{ animation: 'spin 1s linear infinite', display: 'inline-block', color: 'var(--accent,#6366f1)' }}>⟳</span>
|
||||
)}
|
||||
{docContext.status === 'ready' && <FileText size={13} color="#16a34a" />}
|
||||
{docContext.status === 'error' && <AlertCircle size={13} color="#dc2626" />}
|
||||
|
||||
<span style={{
|
||||
fontWeight: 600, fontSize: 11,
|
||||
color: docContext.status === 'error' ? '#dc2626'
|
||||
: docContext.status === 'ready' ? '#16a34a'
|
||||
: 'var(--accent,#6366f1)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{t.ragchat.attachContextBadge}
|
||||
</span>
|
||||
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--fg)' }}
|
||||
title={docContext.filename}>
|
||||
{docContext.filename}
|
||||
</span>
|
||||
|
||||
{docContext.status === 'ready' && (
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', flexShrink: 0 }}>
|
||||
{(docContext.charCount / 1000).toFixed(1)}k chars
|
||||
{docContext.truncated ? ` · ${t.ragchat.attachTruncated}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{docContext.status === 'extracting' && (
|
||||
<span style={{ fontSize: 11, color: 'var(--accent,#6366f1)', flexShrink: 0 }}>
|
||||
{t.ragchat.attachExtracting}
|
||||
</span>
|
||||
)}
|
||||
{docContext.status === 'error' && (
|
||||
<span style={{ fontSize: 11, color: '#dc2626', flexShrink: 0 }} title={docContext.errorMsg}>
|
||||
{t.ragchat.attachError}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Clear button */}
|
||||
<button
|
||||
onClick={() => setDocContext(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 2, fontSize: 11, flexShrink: 0 }}
|
||||
title={t.ragchat.attachClearLabel}
|
||||
>
|
||||
<X size={11} /> {t.ragchat.attachClearLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={t.ragchat.attachAccept}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<div className="composer-row">
|
||||
{/* Paperclip button — replaces attached doc when clicked again */}
|
||||
<button
|
||||
className="btn icon-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={streaming || docContext?.status === 'extracting'}
|
||||
title={t.ragchat.attachBtn}
|
||||
style={{ flexShrink: 0, padding: '8px', color: docContext?.status === 'ready' ? 'var(--accent, #6366f1)' : undefined }}
|
||||
>
|
||||
<Paperclip size={15} />
|
||||
</button>
|
||||
<textarea
|
||||
className="composer-input"
|
||||
placeholder={t.ragchat.inputPlaceholder}
|
||||
value={inputDraft}
|
||||
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => send()}
|
||||
disabled={!inputDraft.trim() || streaming}
|
||||
onClick={() => void send()}
|
||||
disabled={!inputDraft.trim() || streaming || docContext?.status === 'extracting'}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user