2026-06-08 11:16:28 +08:00
|
|
|
import { useRef, useEffect, useCallback, useState } from 'react';
|
2026-06-03 17:58:38 +08:00
|
|
|
import { Topbar } from '../../components/layout/Topbar';
|
2026-07-02 22:03:39 +08:00
|
|
|
import { Send, Download, Zap, Paperclip, X, FileText, AlertCircle } from 'lucide-react';
|
2026-06-08 11:16:28 +08:00
|
|
|
import { usePageState } from '../../contexts';
|
|
|
|
|
import type { RagCitation } from '../../contexts';
|
2026-06-10 11:10:36 +08:00
|
|
|
import { useLanguage } from '../../contexts/LanguageContext';
|
2026-07-02 22:03:39 +08:00
|
|
|
import { agenticChat } from '../../api/rag';
|
|
|
|
|
import type { SSEMessage } from '../../api/index';
|
2026-06-03 17:58:38 +08:00
|
|
|
|
2026-06-05 18:00:31 +08:00
|
|
|
const TOKEN_KEY = 'auth_token';
|
|
|
|
|
function authHeader(): Record<string, string> {
|
|
|
|
|
const t = localStorage.getItem(TOKEN_KEY);
|
|
|
|
|
return t ? { Authorization: `Bearer ${t}` } : {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 22:03:39 +08:00
|
|
|
// ── 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 ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-06-04 15:43:44 +08:00
|
|
|
// Map a raw source doc from the backend "retrieved" event to our Citation shape.
|
2026-06-08 11:16:28 +08:00
|
|
|
function mapSource(s: Record<string, unknown>, idx: number): RagCitation {
|
2026-06-04 15:43:44 +08:00
|
|
|
const rawScore = typeof s.score === 'number' ? s.score : 0;
|
|
|
|
|
const displayScore = rawScore <= 1 ? Math.round(rawScore * 100) : Math.round(rawScore);
|
|
|
|
|
return {
|
|
|
|
|
index: idx,
|
|
|
|
|
score: displayScore,
|
|
|
|
|
name: String(s.doc_name ?? ''),
|
|
|
|
|
clause: String(s.clause ?? s.section_title ?? ''),
|
|
|
|
|
snippet: String(s.preview ?? s.text ?? ''),
|
|
|
|
|
docId: s.doc_id ? String(s.doc_id) : undefined,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-06-03 17:58:38 +08:00
|
|
|
|
2026-06-04 15:43:44 +08:00
|
|
|
// Parse message text and replace [N] with clickable <button class="cite-ref"> elements.
|
|
|
|
|
function renderWithCitations(
|
|
|
|
|
text: string,
|
|
|
|
|
onCiteClick: (n: number) => void,
|
|
|
|
|
): React.ReactNode[] {
|
|
|
|
|
const parts = text.split(/(\[\d+\])/g);
|
|
|
|
|
return parts.map((part, i) => {
|
|
|
|
|
const m = part.match(/^\[(\d+)\]$/);
|
|
|
|
|
if (m) {
|
|
|
|
|
const n = parseInt(m[1], 10);
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={i}
|
|
|
|
|
className="cite-ref"
|
|
|
|
|
onClick={() => onCiteClick(n)}
|
|
|
|
|
title={`Jump to source [${n}]`}
|
|
|
|
|
>
|
|
|
|
|
{n}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return part;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MOCK_QUICK = [
|
2026-06-03 17:58:38 +08:00
|
|
|
'What does EU AI Act Art. 9 require for risk management?',
|
|
|
|
|
'Which documents need CSMS certification?',
|
|
|
|
|
'Summarize MIIT training data rules',
|
|
|
|
|
'What are high-risk AI categories under Annex III?',
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-03 17:16:00 +08:00
|
|
|
export function RagChatPage() {
|
2026-06-08 11:16:28 +08:00
|
|
|
// All persistent state lives in PageStateContext — survives route changes
|
|
|
|
|
const { ragState, setRagState, ragStreamingRef, ragAbortRef } = usePageState();
|
2026-06-10 11:10:36 +08:00
|
|
|
const { t } = useLanguage();
|
2026-06-08 11:16:28 +08:00
|
|
|
const { messages, citations, sessionId, inputDraft } = ragState;
|
|
|
|
|
|
|
|
|
|
// Local-only UI state: highlighted citation and streaming indicator
|
|
|
|
|
// These are fine to reset on navigation since they're transient UI feedback
|
2026-06-04 15:43:44 +08:00
|
|
|
const [highlightedCit, setHighlightedCit] = useState<number | null>(null);
|
2026-06-08 11:16:28 +08:00
|
|
|
const [streaming, setStreaming] = useState(ragStreamingRef.current);
|
|
|
|
|
const [quickPrompts, setQuickPrompts] = useState<string[]>(MOCK_QUICK);
|
2026-06-04 15:43:44 +08:00
|
|
|
|
2026-07-02 22:03:39 +08:00
|
|
|
// 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);
|
|
|
|
|
|
2026-06-03 17:58:38 +08:00
|
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
2026-06-04 15:43:44 +08:00
|
|
|
const citRailRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const citItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
2026-06-03 17:58:38 +08:00
|
|
|
|
2026-07-02 22:03:39 +08:00
|
|
|
// ── 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 11:16:28 +08:00
|
|
|
// Fetch quick questions from backend on mount (only once per session)
|
2026-06-04 15:43:44 +08:00
|
|
|
useEffect(() => {
|
2026-06-05 18:00:31 +08:00
|
|
|
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
|
2026-06-04 15:43:44 +08:00
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(d => {
|
|
|
|
|
if (Array.isArray(d?.questions) && d.questions.length > 0) {
|
|
|
|
|
setQuickPrompts(d.questions.map((q: { question: string }) => q.question));
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => { /* keep mock */ });
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Auto-scroll to latest message
|
2026-06-03 17:58:38 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
|
|
|
}, [messages]);
|
|
|
|
|
|
2026-06-04 15:43:44 +08:00
|
|
|
// Jump to citation N in the rail and highlight it
|
|
|
|
|
const jumpToCitation = useCallback((n: number) => {
|
|
|
|
|
setHighlightedCit(n);
|
|
|
|
|
const el = citItemRefs.current[n];
|
|
|
|
|
if (el) {
|
|
|
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
|
|
|
}
|
|
|
|
|
setTimeout(() => setHighlightedCit(h => h === n ? null : h), 3000);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-03 17:58:38 +08:00
|
|
|
async function send(text?: string) {
|
2026-06-08 11:16:28 +08:00
|
|
|
const q = (text ?? inputDraft).trim();
|
2026-07-02 22:03:39 +08:00
|
|
|
// Block send while a document is still being extracted
|
|
|
|
|
if (!q || ragStreamingRef.current || docContext?.status === 'extracting') return;
|
|
|
|
|
|
2026-06-08 11:16:28 +08:00
|
|
|
setRagState(s => ({ ...s, inputDraft: '' }));
|
2026-06-03 17:58:38 +08:00
|
|
|
|
2026-07-02 22:03:39 +08:00
|
|
|
// Show document context badge in user message bubble when active
|
|
|
|
|
const docPrefix = docContext?.status === 'ready'
|
|
|
|
|
? `📄 ${docContext.filename}\n`
|
|
|
|
|
: '';
|
|
|
|
|
const displayQuery = docPrefix + q;
|
|
|
|
|
|
2026-06-08 11:16:28 +08:00
|
|
|
const userMsgId = Date.now().toString();
|
2026-06-03 17:58:38 +08:00
|
|
|
const assistantId = (Date.now() + 1).toString();
|
2026-06-08 11:16:28 +08:00
|
|
|
|
|
|
|
|
setRagState(s => ({
|
|
|
|
|
...s,
|
|
|
|
|
messages: [
|
|
|
|
|
...s.messages,
|
2026-07-02 22:03:39 +08:00
|
|
|
{ id: userMsgId, role: 'user', text: displayQuery },
|
2026-06-08 11:16:28 +08:00
|
|
|
{ id: assistantId, role: 'assistant', text: '' },
|
|
|
|
|
],
|
|
|
|
|
citations: [],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
ragStreamingRef.current = true;
|
2026-06-03 17:58:38 +08:00
|
|
|
setStreaming(true);
|
2026-06-04 15:43:44 +08:00
|
|
|
setHighlightedCit(null);
|
2026-06-03 17:58:38 +08:00
|
|
|
|
2026-07-02 22:03:39 +08:00
|
|
|
// P0-1: reset thinking panel for new query
|
|
|
|
|
if (agenticMode) {
|
|
|
|
|
setThinkingSteps([]);
|
|
|
|
|
setThinkingExpanded(true);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 17:58:38 +08:00
|
|
|
const ctrl = new AbortController();
|
2026-06-08 11:16:28 +08:00
|
|
|
ragAbortRef.current = ctrl;
|
2026-06-03 17:58:38 +08:00
|
|
|
|
2026-07-02 22:03:39 +08:00
|
|
|
if (agenticMode) {
|
|
|
|
|
// ── Agentic path ────────────────────────────────────────────────────
|
2026-06-08 11:16:28 +08:00
|
|
|
const newCitations: RagCitation[] = [];
|
2026-06-03 17:58:38 +08:00
|
|
|
|
2026-07-02 22:03:39 +08:00
|
|
|
const handleMessage = (msg: SSEMessage) => {
|
|
|
|
|
if (msg.type === 'session') {
|
|
|
|
|
if (msg.session_id) setRagState(s => ({ ...s, sessionId: msg.session_id! }));
|
2026-06-04 15:43:44 +08:00
|
|
|
|
2026-07-02 22:03:39 +08:00
|
|
|
} 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;
|
2026-06-03 17:58:38 +08:00
|
|
|
}
|
2026-07-02 22:03:39 +08:00
|
|
|
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
|
|
|
|
|
),
|
|
|
|
|
}));
|
2026-06-03 17:58:38 +08:00
|
|
|
}
|
2026-07-02 22:03:39 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-03 17:58:38 +08:00
|
|
|
}
|
2026-07-02 22:03:39 +08:00
|
|
|
|
|
|
|
|
} 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);
|
2026-06-03 17:58:38 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lastAssistantId = [...messages].reverse().find(m => m.role === 'assistant')?.id;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="chat-page">
|
|
|
|
|
<Topbar
|
2026-06-10 11:10:36 +08:00
|
|
|
title={t.ragchat.topbarTitle}
|
2026-06-04 15:43:44 +08:00
|
|
|
actions={
|
|
|
|
|
<button
|
|
|
|
|
className="btn sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const text = messages.map(m => `${m.role === 'user' ? 'Q' : 'A'}: ${m.text}`).join('\n\n');
|
|
|
|
|
const blob = new Blob([text], { type: 'text/plain' });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement('a'); a.href = url; a.download = 'chat-export.txt'; a.click();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-06-10 11:10:36 +08:00
|
|
|
<Download size={13} />{t.ragchat.exportBtn}
|
2026-06-04 15:43:44 +08:00
|
|
|
</button>
|
|
|
|
|
}
|
2026-06-03 17:58:38 +08:00
|
|
|
/>
|
2026-06-04 15:43:44 +08:00
|
|
|
|
2026-06-03 17:58:38 +08:00
|
|
|
<div className="chat-body">
|
2026-06-04 15:43:44 +08:00
|
|
|
{/* ── History pane ── */}
|
2026-06-03 17:58:38 +08:00
|
|
|
<div className="history-pane">
|
2026-06-10 11:10:36 +08:00
|
|
|
<div className="history-header">{t.ragchat.quickPromptsHeader}</div>
|
2026-06-04 15:43:44 +08:00
|
|
|
{quickPrompts.map(q => (
|
|
|
|
|
<button key={q} className="quick-item" onClick={() => send(q)}>
|
|
|
|
|
{q}
|
|
|
|
|
</button>
|
2026-06-03 17:58:38 +08:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-04 15:43:44 +08:00
|
|
|
{/* ── Chat main ── */}
|
2026-06-03 17:58:38 +08:00
|
|
|
<div className="chat-main">
|
2026-07-02 22:03:39 +08:00
|
|
|
{/* 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}
|
|
|
|
|
>
|
2026-06-03 17:58:38 +08:00
|
|
|
{messages.map(msg => (
|
|
|
|
|
<div key={msg.id} className={`message msg-${msg.role}`}>
|
|
|
|
|
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
|
|
|
|
|
<div className="msg-bubble">
|
2026-06-04 15:43:44 +08:00
|
|
|
{msg.role === 'assistant'
|
|
|
|
|
? renderWithCitations(msg.text, jumpToCitation)
|
|
|
|
|
: msg.text
|
|
|
|
|
}
|
2026-06-03 17:58:38 +08:00
|
|
|
{streaming && msg.id === lastAssistantId && (
|
|
|
|
|
<span className="blink-cursor">▋</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{msg.role === 'user' && <div className="msg-avatar user-av">You</div>}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
<div ref={bottomRef} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="composer">
|
|
|
|
|
<div className="quick-chips">
|
2026-06-04 15:43:44 +08:00
|
|
|
{quickPrompts.slice(0, 3).map(q => (
|
2026-06-03 17:58:38 +08:00
|
|
|
<button key={q} className="chip" onClick={() => send(q)}>
|
|
|
|
|
{q.length > 42 ? q.slice(0, 42) + '…' : q}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-07-02 22:03:39 +08:00
|
|
|
{/* 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}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-06-03 17:58:38 +08:00
|
|
|
<div className="composer-row">
|
2026-07-02 22:03:39 +08:00
|
|
|
{/* 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>
|
2026-06-03 17:58:38 +08:00
|
|
|
<textarea
|
|
|
|
|
className="composer-input"
|
2026-06-10 11:10:36 +08:00
|
|
|
placeholder={t.ragchat.inputPlaceholder}
|
2026-06-08 11:16:28 +08:00
|
|
|
value={inputDraft}
|
|
|
|
|
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
|
2026-07-02 22:03:39 +08:00
|
|
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
|
2026-06-03 17:58:38 +08:00
|
|
|
rows={2}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
className="btn primary"
|
2026-07-02 22:03:39 +08:00
|
|
|
onClick={() => void send()}
|
|
|
|
|
disabled={!inputDraft.trim() || streaming || docContext?.status === 'extracting'}
|
2026-06-03 17:58:38 +08:00
|
|
|
>
|
|
|
|
|
<Send size={14} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-04 15:43:44 +08:00
|
|
|
{/* ── Citation rail ── */}
|
|
|
|
|
<div className="citation-rail" ref={citRailRef}>
|
|
|
|
|
<div className="citation-header">
|
2026-06-10 11:10:36 +08:00
|
|
|
{t.ragchat.citationsHeader}{citations.length > 0 && ` (${citations.length})`}
|
2026-06-04 15:43:44 +08:00
|
|
|
</div>
|
|
|
|
|
{citations.length === 0 && (
|
|
|
|
|
<p style={{ padding: '12px 16px', fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
{t.ragchat.citationsEmpty}
|
2026-06-04 15:43:44 +08:00
|
|
|
</p>
|
|
|
|
|
)}
|
2026-06-03 17:58:38 +08:00
|
|
|
{citations.map(c => (
|
2026-06-04 15:43:44 +08:00
|
|
|
<div
|
|
|
|
|
key={c.index}
|
|
|
|
|
ref={el => { citItemRefs.current[c.index] = el; }}
|
|
|
|
|
className={`citation-item${highlightedCit === c.index ? ' highlighted' : ''}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="cit-index">[{c.index}]</div>
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 3 }}>
|
|
|
|
|
<div className="cit-name">{c.name}</div>
|
|
|
|
|
{c.clause && <span className="cit-clause">{c.clause}</span>}
|
|
|
|
|
<span className="cit-score" style={{ marginLeft: 'auto' }}>{c.score}%</span>
|
|
|
|
|
</div>
|
2026-06-03 17:58:38 +08:00
|
|
|
<div className="cit-snippet">{c.snippet}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-06-03 17:16:00 +08:00
|
|
|
}
|