import { useState, useRef, useEffect } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { User, Loader2, ArrowUp, ChevronDown, Check, Square, Plus, Database, Wand2, CheckCircle2, Table, XCircle, Settings, ExternalLink, Download, Copy, Mic, X, Compass, RotateCcw } from "lucide-react"; import { api } from "@/lib/api"; import { a2aApi, type A2ARemoteAgent, type A2ATask, type A2ASubscribeEvent, extractTextFromParts, type A2ASendMessagePayload } from "@/api/a2a"; import { type ChartSpec } from "@/store/visualizationStore"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import { useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { InlineVisualizationCard } from "./InlineVisualizationCard"; import { useProjectStore } from "@/store/projectStore"; import { SlashCommandMenu } from "./SlashCommandMenu"; import { ArtifactPanel } from "./ArtifactPanel"; interface Message { id: string; role: 'user' | 'assistant'; content: string; awaitingFirstToken?: boolean; viz?: MessageViz; progressLogs?: string[]; routeInfo?: string; reasoningContent?: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; artifacts?: MessageArtifact[]; kbCitations?: KnowledgeCitation[]; a2aTaskId?: string; a2aTaskState?: string; a2aRouteMode?: string; a2aRemoteAgentId?: number | null; a2aInputText?: string; a2aError?: string; } interface MessageViz { sql: string; rows: unknown[]; chartSpec: ChartSpec | null; canVisualize: boolean; reasoning?: string; error?: string | null; } interface MessageArtifact { name: string; mime_type: string; size: number; download_url: string; previewable: boolean; preview_url?: string; } interface KnowledgeCitation { doc_id: string; title: string; score: number; chunk: string; metadata?: Record; } interface ArtifactPreviewTarget { name: string; mimeType: string; previewUrl: string; downloadUrl: string; } const REPORT_HTML_BLOCK_REGEX = /([\s\S]*?)/i; const splitReportHtml = (content: string): { markdown: string; reportHtml: string | null } => { if (!content) { return { markdown: "", reportHtml: null }; } const match = content.match(REPORT_HTML_BLOCK_REGEX); if (!match) { return { markdown: content, reportHtml: null }; } const reportHtml = (match[1] || "").trim(); const markdown = content.replace(REPORT_HTML_BLOCK_REGEX, "").trim(); return { markdown, reportHtml: reportHtml || null }; }; const HTML_FILE_REGEX = /data[\\/]data[\\/]([a-zA-Z0-9_-]+\.html?)/i; const extractExternalReport = (content: string): string | null => { if (!content) return null; const match = content.match(HTML_FILE_REGEX); if (match && match[1]) { return `/reports/${match[1]}`; } return null; }; interface ModelConfig { id: string; name?: string; model: string; provider: string; is_active: boolean; } interface DataFileContext { filename: string; url: string; columns?: string[]; summary?: string; } interface Skill { id: string; name: string; description?: string; type: string; } interface KnowledgeBaseOption { id: string; name: string; } const dedupeSkillsById = (skills: Skill[]): Skill[] => { const map = new Map(); for (const skill of skills) { const id = (skill.id || "").trim(); if (!id || map.has(id)) continue; map.set(id, skill); } return Array.from(map.values()); }; interface SessionData { key: string; metadata?: { active_data_file?: DataFileContext | null; selected_data_source?: string | null; selected_knowledge_base_id?: string | null; [key: string]: unknown; }; messages: SessionMessage[]; } interface SessionMessage { role: string; content: string; tool_calls?: unknown[]; viz?: unknown; reasoning_content?: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; artifacts?: unknown; kb_citations?: unknown; [key: string]: unknown; } const normalizeArtifacts = (raw: unknown): MessageArtifact[] => { if (!Array.isArray(raw)) return []; return raw.reduce((acc, item) => { if (!item || typeof item !== "object") return acc; const source = item as Record; const name = typeof source.name === "string" ? source.name : ""; const mimeType = typeof source.mime_type === "string" ? source.mime_type : typeof source.mimeType === "string" ? source.mimeType : "application/octet-stream"; const size = typeof source.size === "number" ? source.size : 0; const downloadUrl = typeof source.download_url === "string" ? source.download_url : typeof source.downloadUrl === "string" ? source.downloadUrl : ""; const previewable = Boolean(source.previewable); const previewUrl = typeof source.preview_url === "string" ? source.preview_url : typeof source.previewUrl === "string" ? source.previewUrl : undefined; if (!name || !downloadUrl) return acc; const normalized: MessageArtifact = { name, mime_type: mimeType, size, download_url: downloadUrl, previewable, preview_url: previewUrl, }; acc.push(normalized); return acc; }, []); }; const normalizeKnowledgeCitations = (raw: unknown): KnowledgeCitation[] => { if (!Array.isArray(raw)) return []; return raw.reduce((acc, item) => { if (!item || typeof item !== "object") return acc; const source = item as Record; const title = typeof source.title === "string" ? source.title : ""; const chunk = typeof source.chunk === "string" ? source.chunk : ""; const score = typeof source.score === "number" ? source.score : Number(source.score || 0); if (!title || !chunk) return acc; acc.push({ doc_id: typeof source.doc_id === "string" ? source.doc_id : "", title, score: Number.isFinite(score) ? score : 0, chunk, metadata: source.metadata && typeof source.metadata === "object" ? source.metadata as Record : undefined, }); return acc; }, []); }; const getErrorMessage = (error: unknown): string => { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return ""; }; export function ChatInterface() { const { t } = useTranslation(); const [messagesBySession, setMessagesBySession] = useState>({}); const [input, setInput] = useState(""); const [selectedDataSource, setSelectedDataSource] = useState(""); const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState(""); const [availableKnowledgeBases, setAvailableKnowledgeBases] = useState([]); const [isMenuOpen, setIsMenuOpen] = useState(false); const [artifactPreview, setArtifactPreview] = useState(null); const [collapsedThinkingByMessage, setCollapsedThinkingByMessage] = useState>({}); const [thinkingCopiedByMessage, setThinkingCopiedByMessage] = useState>({}); const scrollRef = useRef(null); const location = useLocation(); const { currentProject } = useProjectStore(); // Slash Command State const [slashQuery, setSlashQuery] = useState(null); const [slashIndex, setSlashIndex] = useState(0); const [availableSkills, setAvailableSkills] = useState([]); const [selectedSkillIds, setSelectedSkillIds] = useState([]); const [a2aEnabled, setA2aEnabled] = useState(false); const [a2aRouteMode, setA2aRouteMode] = useState<"auto" | "local" | "a2a" | "a2a_first" | "local_first">("auto"); const [a2aRemoteAgents, setA2aRemoteAgents] = useState([]); const [selectedA2aAgentId, setSelectedA2aAgentId] = useState(""); const [a2aTaskStateFilter, setA2aTaskStateFilter] = useState("all"); const [a2aTasks, setA2aTasks] = useState([]); const [isA2aTaskLoading, setIsA2aTaskLoading] = useState(false); const filteredSlashSkills = slashQuery !== null ? availableSkills.filter(s => s.name.toLowerCase().includes(slashQuery.toLowerCase())) : []; const handleSelectSlashSkill = (skill: Skill) => { if (!selectedSkillIds.includes(skill.id)) { setSelectedSkillIds(prev => [...prev, skill.id]); } // Remove the slash command from input // Match the last occurrence of /query const match = input.match(/(?:^|\s)\/([a-zA-Z0-9_-]*)$/); if (match && match.index !== undefined) { // match[0] includes the leading space if present const prefix = input.slice(0, match.index); const suffix = input.slice(match.index + match[0].length); setInput((prefix + suffix).trim()); } setSlashQuery(null); }; const handleInputKeyDown = (e: React.KeyboardEvent) => { // Avoid triggering Enter when using IME (Input Method Editor) for CJK characters if (e.nativeEvent.isComposing) { return; } if (slashQuery !== null && filteredSlashSkills.length > 0) { if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex(prev => Math.max(0, prev - 1)); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex(prev => Math.min(filteredSlashSkills.length - 1, prev + 1)); return; } if (e.key === 'Enter') { e.preventDefault(); handleSelectSlashSkill(filteredSlashSkills[slashIndex]); return; } if (e.key === 'Escape') { e.preventDefault(); setSlashQuery(null); return; } } if (e.key === 'Enter' && !isLoading) { handleSend(); } }; const handleInputChange = (e: React.ChangeEvent) => { const val = e.target.value; setInput(val); // Simple slash detection: if the last word starts with / const match = val.match(/(?:^|\s)\/([a-zA-Z0-9_-]*)$/); if (match) { setSlashQuery(match[1]); setSlashIndex(0); } else { setSlashQuery(null); } }; const setMessagesForSession = (sessionKey: string, updater: React.SetStateAction) => { setMessagesBySession(prev => { const current = prev[sessionKey] || []; const next = typeof updater === 'function' ? (updater as (msgs: Message[]) => Message[])(current) : updater; return { ...prev, [sessionKey]: next }; }); }; const setIsLoadingForSession = (sessionKey: string, loading: boolean) => { setLoadingBySession(prev => ({ ...prev, [sessionKey]: loading })); }; const queryParams = new URLSearchParams(location.search); const activeSessionKey = queryParams.get("session") || "api:default"; const messages = messagesBySession[activeSessionKey] || []; const [loadingBySession, setLoadingBySession] = useState>({}); const isLoading = loadingBySession[activeSessionKey] || false; const generatingSessionsRef = useRef>({}); const abortControllersRef = useRef>({}); const a2aSubscribeControllersRef = useRef>({}); const a2aActiveTaskBySessionRef = useRef>({}); // Model selection state const [models, setModels] = useState([]); const [selectedModelId, setSelectedModelId] = useState(""); const isTerminalA2aState = (state?: string) => { return state ? ["COMPLETED", "FAILED", "CANCELED", "REJECTED"].includes(state) : false; }; const parseA2aErrorMessage = (raw?: string | null) => { if (!raw) return ""; try { const parsed = JSON.parse(raw) as { message?: string }; return parsed.message || raw; } catch { return raw; } }; const fetchA2aAgentsAndTasks = async (projectId: number, stateFilter: string = a2aTaskStateFilter) => { setIsA2aTaskLoading(true); try { const [agents, tasks] = await Promise.all([ a2aApi.listRemoteAgents(projectId), a2aApi.listTasks(projectId, stateFilter), ]); setA2aRemoteAgents(agents || []); setA2aTasks(tasks || []); if (selectedA2aAgentId && !(agents || []).some((agent) => String(agent.id) === selectedA2aAgentId)) { setSelectedA2aAgentId(""); } } catch (error) { console.error("Failed to fetch A2A agents or tasks", error); } finally { setIsA2aTaskLoading(false); } }; // Listen for model changes from the ProjectSwitcher useEffect(() => { const handleModelChange = (e: Event) => { const customEvent = e as CustomEvent; if (customEvent.detail) { setSelectedModelId(customEvent.detail); } }; window.addEventListener('nanobot:model-changed', handleModelChange); return () => { window.removeEventListener('nanobot:model-changed', handleModelChange); }; }, []); // Data Source selection state const [availableDataSources, setAvailableDataSources] = useState<{id: string, name: string}[]>([]); // File upload state const [attachedFile, setAttachedFile] = useState(null); const [activeDataFile, setActiveDataFile] = useState(null); const [, setIsUploading] = useState(false); const fileInputRef = useRef(null); // Speech Recognition State const [isRecording, setIsRecording] = useState(false); const [isTranscribing, setIsTranscribing] = useState(false); const [recordingLevel, setRecordingLevel] = useState(0); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const shouldTranscribeRef = useRef(true); const audioContextRef = useRef(null); const audioAnimationRef = useRef(null); const stopAudioMeter = () => { if (audioAnimationRef.current) { cancelAnimationFrame(audioAnimationRef.current); audioAnimationRef.current = null; } if (audioContextRef.current) { void audioContextRef.current.close(); audioContextRef.current = null; } setRecordingLevel(0); }; const startAudioMeter = (stream: MediaStream) => { const audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); analyser.fftSize = 1024; source.connect(analyser); audioContextRef.current = audioContext; const dataArray = new Uint8Array(analyser.frequencyBinCount); const tick = () => { analyser.getByteTimeDomainData(dataArray); let sum = 0; for (let i = 0; i < dataArray.length; i += 1) { const normalized = (dataArray[i] - 128) / 128; sum += normalized * normalized; } const rms = Math.sqrt(sum / dataArray.length); const level = Math.min(1, rms * 7); setRecordingLevel(level); audioAnimationRef.current = requestAnimationFrame(tick); }; tick(); }; const startRecording = async () => { try { const voiceEnabled = localStorage.getItem("whisper_enabled") === "true"; if (!voiceEnabled) { alert(t('voiceInputNotEnabled', '语音输入未开启,请先到左下角用户名 -> 更多 -> 语音输入配置中开启')); return; } const configuredWhisperUrl = (localStorage.getItem("whisper_url") || "").trim(); if (!configuredWhisperUrl) { alert(t('voiceServerNotConfigured', '请先配置语音识别服务地址:点击左下角用户名 -> 更多 -> 语音输入配置')); return; } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream); mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = []; shouldTranscribeRef.current = true; startAudioMeter(stream); mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) { audioChunksRef.current.push(e.data); } }; mediaRecorder.onstop = async () => { stopAudioMeter(); if (!shouldTranscribeRef.current) { shouldTranscribeRef.current = true; return; } setIsTranscribing(true); try { const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); const formData = new FormData(); formData.append("file", audioBlob, "audio.webm"); const baseUrl = configuredWhisperUrl; const response = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { method: "POST", body: formData, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const output = await response.json(); if (output && output.text) { setInput((prev) => prev + (prev ? " " : "") + output.text.trim()); } } catch (err) { console.error("Transcription error:", err); } finally { setIsTranscribing(false); } }; mediaRecorder.start(); setIsRecording(true); } catch (err) { console.error("Microphone access denied:", err); } }; const stopRecording = () => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); setIsRecording(false); mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop()); } }; const confirmRecording = () => { shouldTranscribeRef.current = true; stopRecording(); }; const cancelRecording = () => { shouldTranscribeRef.current = false; stopRecording(); }; useEffect(() => { return () => { stopAudioMeter(); if (mediaRecorderRef.current) { mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop()); } }; }, []); useEffect(() => { fetchModels(); }, []); useEffect(() => { if (currentProject) { fetchDataSources(); fetchKnowledgeBases(); void fetchA2aAgentsAndTasks(currentProject.id, a2aTaskStateFilter); } else { setAvailableKnowledgeBases([]); setSelectedKnowledgeBaseId(""); setA2aRemoteAgents([]); setA2aTasks([]); setSelectedA2aAgentId(""); } }, [currentProject, a2aTaskStateFilter]); const fetchDataSources = async () => { if (!currentProject) return; try { const data = await api.get>(`/api/v1/datasources?project_id=${currentProject.id}`); const projectSources = data.map(d => ({ id: `ds:${d.id}`, name: d.name })); setAvailableDataSources(projectSources); if (selectedDataSource && !projectSources.find(ds => ds.id === selectedDataSource)) { setSelectedDataSource(""); void syncSessionContext({ selected_data_source: null }); } } catch (e) { console.error("Failed to fetch data sources", e); } }; const fetchKnowledgeBases = async () => { if (!currentProject) return; try { const data = await api.get>(`/api/v1/knowledge-bases?project_id=${currentProject.id}`); const projectKnowledgeBases = (data || []).map((item) => ({ id: item.id, name: item.name })); setAvailableKnowledgeBases(projectKnowledgeBases); if (selectedKnowledgeBaseId && !projectKnowledgeBases.find((item) => item.id === selectedKnowledgeBaseId)) { setSelectedKnowledgeBaseId(""); void syncSessionContext({ selected_knowledge_base_id: null }); } } catch (e) { console.error("Failed to fetch knowledge bases", e); } }; const syncSessionContext = async (payload: { active_data_file?: DataFileContext | null; selected_data_source?: string | null; selected_knowledge_base_id?: string | null; }) => { try { await api.put(`/agent-core/sessions/${encodeURIComponent(activeSessionKey)}/context-file`, payload); } catch (e) { console.error("Failed to sync session context", e); } }; const handleSelectKnowledgeBase = async (knowledgeBaseId: string) => { setSelectedKnowledgeBaseId(knowledgeBaseId); await syncSessionContext({ selected_knowledge_base_id: knowledgeBaseId }); }; const handleClearKnowledgeBase = async () => { setSelectedKnowledgeBaseId(""); await syncSessionContext({ selected_knowledge_base_id: null }); }; const handleSelectDataSource = async (sourceId: string) => { setSelectedDataSource(sourceId); await syncSessionContext({ selected_data_source: sourceId }); }; const handleClearDataSource = async () => { setSelectedDataSource(""); await syncSessionContext({ selected_data_source: null }); }; useEffect(() => { const fetchSessionData = async () => { if (generatingSessionsRef.current[activeSessionKey]) { return; // Do not fetch if we are currently generating for this session } setIsLoadingForSession(activeSessionKey, true); setSelectedSkillIds([]); try { const data = await api.get(`/agent-core/sessions/${activeSessionKey}`); if (data.messages && data.messages.length > 0) { const formattedMessages = data.messages .filter((m) => { if (m.role === 'system' || m.role === 'tool' || m.role === 'function') return false; if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0 && !m.viz && (!m.content || m.content.trim() === '')) return false; return true; }) .map((m, idx) => { let cleanContent = m.content || ""; // Remove injected system prompt instructions from user messages if present if (m.role === 'user') { if (cleanContent.startsWith("[Runtime Context")) { const splitIndex = cleanContent.indexOf("\n\n"); if (splitIndex !== -1) { cleanContent = cleanContent.substring(splitIndex + 2); } else { cleanContent = ""; } } else if (cleanContent.startsWith("[System:")) { // Fallback for older messages containing [System: ...] wrapper cleanContent = cleanContent.replace(/^\[System:[\s\S]*?\]\n*/i, ''); } cleanContent = cleanContent.trim(); } return { id: `${Date.now()}-${idx}`, role: m.role as 'user' | 'assistant', content: cleanContent, viz: m.viz ? buildMessageViz(m.viz) : undefined, reasoningContent: typeof m.reasoning_content === "string" ? m.reasoning_content : undefined, usage: m.usage, artifacts: normalizeArtifacts(m.artifacts), kbCitations: normalizeKnowledgeCitations(m.kb_citations), }; }); setMessagesForSession(activeSessionKey, formattedMessages); } else { setMessagesForSession(activeSessionKey, []); } const restoredFile = data.metadata?.active_data_file || null; const restoredSource = data.metadata?.selected_data_source || ""; const restoredKnowledgeBaseId = data.metadata?.selected_knowledge_base_id || ""; setActiveDataFile(restoredFile); setSelectedDataSource(restoredSource); setSelectedKnowledgeBaseId(restoredKnowledgeBaseId); setAttachedFile(null); } catch (e) { console.error("Failed to fetch session messages", e); setMessagesForSession(activeSessionKey, []); setActiveDataFile(null); setSelectedDataSource(""); setSelectedKnowledgeBaseId(""); setAttachedFile(null); } finally { setIsLoadingForSession(activeSessionKey, false); } }; fetchSessionData(); }, [activeSessionKey]); const fetchModels = async () => { try { const data = await api.get("/api/v1/llm"); setModels(data); // Set default model if available const active = data.find(m => m.is_active); if (active) { setSelectedModelId(active.id); } else if (data.length > 0) { setSelectedModelId(data[0].id); } } catch (e) { console.error("Failed to fetch models", e); } }; const currentModel = models.find(m => m.id === selectedModelId); const chartIntentPattern = new RegExp(t('chartIntentPattern'), 'i'); const buildMessageViz = (payload: { sql?: string; result?: unknown; error?: string | null; chart?: { chart_spec?: ChartSpec | null; reasoning?: string; can_visualize?: boolean; chart_type?: string } | null; }): MessageViz => { const rows = Array.isArray(payload.result) ? payload.result : []; const chart = payload.chart ?? undefined; const canVisualize = chart?.can_visualize ?? Boolean(chart?.chart_spec); const chartSpec = chart?.chart_spec ?? null; return { sql: typeof payload.sql === "string" ? payload.sql : "", rows, chartSpec, canVisualize, reasoning: chart?.reasoning, error: payload.error ?? null, }; }; const handleFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setIsUploading(true); const formData = new FormData(); formData.append("file", file); try { const response = await fetch("/api/v1/upload/file", { method: "POST", body: formData, headers: { ...(localStorage.getItem("token") ? { Authorization: `Bearer ${localStorage.getItem("token")}` } : {}), } }); if (!response.ok) { throw new Error("Upload failed"); } const data = await response.json(); const uploadedFile = { filename: file.name, url: data.url, columns: data.columns, summary: data.summary, }; setAttachedFile(uploadedFile); setActiveDataFile(uploadedFile); setSelectedDataSource(""); await syncSessionContext({ active_data_file: uploadedFile, selected_data_source: null }); } catch (error) { console.error("File upload error:", error); // Could show a toast notification here } finally { setIsUploading(false); if (fileInputRef.current) { fileInputRef.current.value = ""; } } }; const handleRemoveFile = async () => { setAttachedFile(null); setActiveDataFile(null); await syncSessionContext({ active_data_file: null }); }; const selectedDataSourceName = availableDataSources.find(ds => ds.id === selectedDataSource)?.name || ""; const selectedKnowledgeBaseName = availableKnowledgeBases.find((item) => item.id === selectedKnowledgeBaseId)?.name || ""; const selectedSkills = availableSkills.filter(skill => selectedSkillIds.includes(skill.id)); const isThinkingCollapsed = (messageId: string) => collapsedThinkingByMessage[messageId] ?? true; const toggleThinkingCollapsed = (messageId: string) => { setCollapsedThinkingByMessage((prev) => ({ ...prev, [messageId]: !(prev[messageId] ?? true) })); }; const copyThinkingContent = async (messageId: string, content: string) => { if (!content.trim()) return; try { await navigator.clipboard.writeText(content); setThinkingCopiedByMessage((prev) => ({ ...prev, [messageId]: true })); window.setTimeout(() => { setThinkingCopiedByMessage((prev) => ({ ...prev, [messageId]: false })); }, 1200); } catch (e) { console.error("Failed to copy thinking content", e); } }; const updateA2aMessageByTaskId = (sessionKey: string, taskId: string, updater: (msg: Message) => Message) => { setMessagesForSession(sessionKey, (prev) => prev.map((msg) => (msg.a2aTaskId === taskId ? updater(msg) : msg)) ); }; const syncTaskSnapshotToMessage = (sessionKey: string, task: A2ATask) => { updateA2aMessageByTaskId(sessionKey, task.id, (msg) => ({ ...msg, a2aTaskState: task.state, content: task.output_text || msg.content, a2aError: parseA2aErrorMessage(task.error_message), awaitingFirstToken: !isTerminalA2aState(task.state), })); }; const applyA2aSubscribeEvent = (sessionKey: string, taskId: string, event: A2ASubscribeEvent) => { if (event.type === "TaskStatusUpdateEvent") { const state = event.task_status || event.status || ""; updateA2aMessageByTaskId(sessionKey, taskId, (msg) => { const currentLogs = msg.progressLogs || []; const nextLog = state ? `${t('a2aStatus')}: ${state}` : ""; const logs = nextLog && currentLogs[currentLogs.length - 1] !== nextLog ? [...currentLogs, nextLog] : currentLogs; return { ...msg, a2aTaskState: state || msg.a2aTaskState, progressLogs: logs, awaitingFirstToken: state ? !isTerminalA2aState(state) : msg.awaitingFirstToken, }; }); return; } if (event.type === "TaskArtifactUpdateEvent") { const content = event.artifact ? extractTextFromParts(event.artifact.parts) : (event.output || ""); if (!content) return; updateA2aMessageByTaskId(sessionKey, taskId, (msg) => ({ ...msg, content, awaitingFirstToken: false, })); } }; const runA2aMessageFlow = async (sessionKey: string, assistantId: string, inputText: string) => { if (!currentProject) return; const payload: A2ASendMessagePayload = { project_id: currentProject.id, message: { role: "user", parts: [{ kind: "text", text: inputText }] }, session_id: sessionKey, route_mode: a2aRouteMode, ...(selectedA2aAgentId ? { remote_agent_id: Number(selectedA2aAgentId) } : {}), metadata: { from_chat: true }, }; const response = await a2aApi.sendMessage(payload); const task = response.task; a2aActiveTaskBySessionRef.current[sessionKey] = task.id; setMessagesForSession(sessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, a2aTaskId: task.id, a2aTaskState: task.state, a2aRouteMode: a2aRouteMode, a2aRemoteAgentId: task.remote_agent_id || null, a2aInputText: inputText, routeInfo: response.routing?.selected || msg.routeInfo, progressLogs: [...(msg.progressLogs || []), `${t('a2aTaskCreated')}: ${task.id}`], } : msg ) ); await fetchA2aAgentsAndTasks(currentProject.id, a2aTaskStateFilter); const subscribeController = new AbortController(); a2aSubscribeControllersRef.current[task.id] = subscribeController; try { await a2aApi.subscribeTask( task.id, (event) => { applyA2aSubscribeEvent(sessionKey, task.id, event); }, subscribeController.signal ); } catch (error) { if (!subscribeController.signal.aborted) { console.error("A2A subscribe failed", error); } } finally { delete a2aSubscribeControllersRef.current[task.id]; const latestTask = await a2aApi.getTask(task.id).catch(() => null); if (latestTask) { syncTaskSnapshotToMessage(sessionKey, latestTask); } if (currentProject) { await fetchA2aAgentsAndTasks(currentProject.id, a2aTaskStateFilter); } } }; const handleCancelA2aTask = async (taskId: string) => { try { await a2aApi.cancelTask(taskId); const controller = a2aSubscribeControllersRef.current[taskId]; if (controller) { controller.abort(); delete a2aSubscribeControllersRef.current[taskId]; } if (currentProject) { await fetchA2aAgentsAndTasks(currentProject.id, a2aTaskStateFilter); } setMessagesForSession(activeSessionKey, (prev) => prev.map((msg) => (msg.a2aTaskId === taskId ? { ...msg, a2aTaskState: "CANCELED", awaitingFirstToken: false } : msg)) ); } catch (error) { console.error("Failed to cancel A2A task", error); } }; const handleRetryA2aTask = async (msg: Message) => { if (!msg.a2aInputText) return; const targetSessionKey = activeSessionKey; const newUserMessage: Message = { id: Date.now().toString(), role: "user", content: msg.a2aInputText }; setMessagesForSession(targetSessionKey, (prev) => [...prev, newUserMessage]); const assistantId = (Date.now() + 1).toString(); setMessagesForSession(targetSessionKey, (prev) => [ ...prev, { id: assistantId, role: "assistant", content: "", awaitingFirstToken: true, progressLogs: [t('requestSubmittedRouting')], }, ]); setIsLoadingForSession(targetSessionKey, true); generatingSessionsRef.current[targetSessionKey] = true; try { await runA2aMessageFlow(targetSessionKey, assistantId, msg.a2aInputText); } catch (error: unknown) { const errorMessage = getErrorMessage(error) || t('unknownError'); setMessagesForSession(targetSessionKey, (prev) => prev.map((item) => item.id === assistantId ? { ...item, awaitingFirstToken: false, a2aTaskState: "FAILED", a2aError: errorMessage, content: item.content || `${t('a2aTaskFailed')}: ${errorMessage}`, } : item ) ); } finally { generatingSessionsRef.current[targetSessionKey] = false; setIsLoadingForSession(targetSessionKey, false); delete a2aActiveTaskBySessionRef.current[targetSessionKey]; } }; const renderActiveSelections = () => { const hasValidDataSourceSelection = Boolean(selectedDataSource && selectedDataSourceName); const selectedAgent = a2aRemoteAgents.find((agent) => String(agent.id) === selectedA2aAgentId); if (!hasValidDataSourceSelection && !selectedKnowledgeBaseId && !a2aEnabled) return null; return (
{a2aEnabled ? (
{`A2A:${a2aRouteMode}${selectedAgent ? ` · ${selectedAgent.name}` : ""}`}
) : null} {hasValidDataSourceSelection ? (
{`${t('dataSource')}:${selectedDataSourceName}`}
) : null} {selectedKnowledgeBaseId ? (
{`${t('knowledgeBase')}:${selectedKnowledgeBaseName || selectedKnowledgeBaseId}`}
) : null} {selectedSkills.map((skill) => (
{`Skill:${skill.name}`}
))}
); }; const renderFileCard = () => { const file = attachedFile || activeDataFile; if (!file) return null; return (
{file.filename}
{t('spreadsheet')}
); }; const renderInputPanel = ({ menuSide, menuOffsetClass, recordingWaveKeyPrefix, showDisclaimer = false, }: { menuSide: "top" | "bottom"; menuOffsetClass: string; recordingWaveKeyPrefix: string; showDisclaimer?: boolean; }) => { return (
{a2aEnabled ? ( <> ) : null}
{renderFileCard()} {renderActiveSelections()}
{t('knowledgeBase')}
{availableKnowledgeBases.length > 0 ? ( availableKnowledgeBases.map((kb) => ( )) ) : (

{t('noKnowledgeBases')}

)} {selectedKnowledgeBaseId ? (
) : null}
{t('dataSource')}
{availableDataSources.length > 0 ? ( availableDataSources.map((ds) => ( )) ) : (

{t('noDataSources')}

)} {selectedDataSource ? (
) : null}
{isRecording ? ( <>
{Array.from({ length: 30 }).map((_, idx) => { const dynamic = Math.abs(Math.sin(Date.now() / 180 + idx * 0.85)); const height = Math.max(4, Math.round((4 + dynamic * 18) * (0.45 + recordingLevel))); return ( ); })}
) : ( <> setSlashQuery(null)} />
)}
{showDisclaimer && (

{t('dataClawDisclaimer')}

)}
); }; useEffect(() => { const fetchSkills = async () => { try { let url = "/api/v1/skills"; if (currentProject) { url += `?project_id=${currentProject.id}`; } const skills = await api.get(url); setAvailableSkills(dedupeSkillsById(skills || [])); } catch (err) { console.error("Failed to fetch skills:", err); } }; fetchSkills(); }, [currentProject]); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages]); const handleForceStop = () => { const activeTaskId = a2aActiveTaskBySessionRef.current[activeSessionKey]; if (activeTaskId) { void handleCancelA2aTask(activeTaskId); delete a2aActiveTaskBySessionRef.current[activeSessionKey]; } const controller = abortControllersRef.current[activeSessionKey]; if (controller) { controller.abort(); } setIsLoadingForSession(activeSessionKey, false); generatingSessionsRef.current[activeSessionKey] = false; setMessagesForSession(activeSessionKey, (prev) => prev.map((msg) => msg.awaitingFirstToken ? { ...msg, awaitingFirstToken: false, content: msg.content || t('outputInterrupted') } : msg ) ); }; const handleSend = async () => { if (!input.trim() || isLoading) return; const targetSessionKey = activeSessionKey; const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input }; setMessagesForSession(targetSessionKey, prev => [...prev, newMessage]); setInput(""); let messagePayload = newMessage.content; const currentAttachedFile = attachedFile; if (currentAttachedFile) { messagePayload = `[${t('userUploadedFile')}: ${currentAttachedFile.filename}]\n[${t('fileContentSummary')}: ${currentAttachedFile.summary || t('none')}]\n[${t('dataColumns')}: ${currentAttachedFile.columns?.join(", ") || t('none')}]\n[${t('fileDownloadLink')}: ${currentAttachedFile.url}]\n\n${newMessage.content}`; setAttachedFile(null); } if (a2aEnabled && currentProject) { generatingSessionsRef.current[targetSessionKey] = true; setIsLoadingForSession(targetSessionKey, true); const assistantId = (Date.now() + 1).toString(); setMessagesForSession(targetSessionKey, (prev) => [ ...prev, { id: assistantId, role: "assistant", content: "", awaitingFirstToken: true, progressLogs: [t('requestSubmittedRouting')], a2aRouteMode, a2aRemoteAgentId: selectedA2aAgentId ? Number(selectedA2aAgentId) : null, a2aInputText: messagePayload, }, ]); try { await runA2aMessageFlow(targetSessionKey, assistantId, messagePayload); } catch (error: unknown) { const errorMessage = getErrorMessage(error) || t('unknownError'); setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, awaitingFirstToken: false, a2aTaskState: "FAILED", a2aError: errorMessage, content: msg.content || `${t('a2aTaskFailed')}: ${errorMessage}`, } : msg ) ); } finally { generatingSessionsRef.current[targetSessionKey] = false; setIsLoadingForSession(targetSessionKey, false); delete a2aActiveTaskBySessionRef.current[targetSessionKey]; window.dispatchEvent(new Event("nanobot:sessions-changed")); } return; } const controller = new AbortController(); abortControllersRef.current[targetSessionKey] = controller; generatingSessionsRef.current[targetSessionKey] = true; setIsLoadingForSession(targetSessionKey, true); try { const assistantId = (Date.now() + 1).toString(); setMessagesForSession(targetSessionKey, prev => [...prev, { id: assistantId, role: "assistant", content: "", awaitingFirstToken: true, progressLogs: [t('requestSubmittedRouting')], }]); const pushProgressLog = (text: string, isReasoningToken: boolean = false) => { if (!text.trim() && !isReasoningToken) return; setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => { if (msg.id !== assistantId) return msg; if (isReasoningToken) { return msg; } else { // 对于普通的阶段性日志,取消 8 条限制,允许滚动查看所有历史 const current = msg.progressLogs || []; if (current[current.length - 1] === text) return msg; const next = [...current, text]; return { ...msg, progressLogs: next }; } }) ); }; const token = localStorage.getItem("token"); const effectiveModelId = selectedModelId || currentModel?.id || ""; let source = selectedDataSource || "postgres"; const useUploadSource = Boolean(currentAttachedFile?.url?.startsWith("local://")); if (useUploadSource) { source = "upload"; } const fileUrl = useUploadSource ? (currentAttachedFile?.url || activeDataFile?.url) : undefined; const preferSqlChart = chartIntentPattern.test(messagePayload); const response = await fetch("/agent-core/chat/stream", { method: "POST", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ message: messagePayload, session_id: targetSessionKey, project_id: currentProject?.id, model_id: effectiveModelId, skill_ids: selectedSkillIds, source, prefer_sql_chart: preferSqlChart, file_url: fileUrl, route_mode: "auto", knowledge_base_id: selectedKnowledgeBaseId || undefined, }), signal: controller.signal, }); if (!response.ok || !response.body) { const err = await response.json().catch(() => ({})); const detail = typeof err.detail === "string" ? err.detail : Array.isArray(err.detail) ? err.detail.map((d: { msg?: string }) => d?.msg).filter(Boolean).join("; ") : ""; throw new Error( detail || `${t("streamResponseFailed")} (${response.status} ${response.statusText})`.trim() ); } const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let streamedText = ""; let streamedViz: MessageViz | null = null; let hasFinalPayload = false; let hasDonePayload = false; let rafPending = false; let renderedText = ""; let reasoningBuffer = ""; let reasoningRafPending = false; const flushReasoning = (force = false) => { if (!reasoningBuffer) return; if (force) { const content = reasoningBuffer; reasoningBuffer = ""; setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, reasoningContent: (msg.reasoningContent || "") + content } : msg ) ); return; } if (reasoningRafPending) return; reasoningRafPending = true; requestAnimationFrame(() => { reasoningRafPending = false; if (!reasoningBuffer) return; const content = reasoningBuffer; reasoningBuffer = ""; setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, reasoningContent: (msg.reasoningContent || "") + content } : msg ) ); }); }; const flushAssistant = (force = false) => { if (streamedText === renderedText && !force) return; if (force) { renderedText = streamedText; setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: streamedText, awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg ) ); return; } if (rafPending) return; rafPending = true; requestAnimationFrame(() => { rafPending = false; if (streamedText === renderedText) return; renderedText = streamedText; setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: streamedText, awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg ) ); }); }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() || ""; for (const eventBlock of events) { const line = eventBlock .split("\n") .find((item) => item.startsWith("data:")); if (!line) continue; const payloadText = line.slice(5).trim(); if (!payloadText) continue; const payload = JSON.parse(payloadText) as { type: string; content?: string; is_reasoning?: boolean; tool_hint?: boolean; sql?: string; result?: unknown; error?: string; selected?: string; reason?: string; chart?: { chart_spec?: ChartSpec | null; reasoning?: string; can_visualize?: boolean; chart_type?: string } | null; artifacts?: unknown; reasoning_content?: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; kb_citations?: unknown; }; if (payload.type === "delta" && payload.content) { streamedText = `${streamedText}${payload.content}`; flushAssistant(false); } if (payload.type === "routing") { const selected = payload.selected === "sql" ? t('sqlAnalysis') : t('generalConversation'); const reason = payload.reason ? `(${payload.reason})` : ""; pushProgressLog(t('routingInfo', { selected, reason })); setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, routeInfo: `${selected}${reason}` } : msg ) ); } if (payload.type === "progress" && payload.content) { if (payload.is_reasoning || payload.tool_hint) { const nextLine = payload.content.endsWith("\n") ? payload.content : `${payload.content}\n`; reasoningBuffer += nextLine; flushReasoning(false); } else { pushProgressLog(payload.content, false); } } if (payload.type === "final") { hasFinalPayload = true; if (typeof payload.content === "string") { streamedText = payload.content; } if (typeof payload.reasoning_content === "string") { reasoningBuffer = ""; setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, reasoningContent: payload.reasoning_content } : msg ) ); } else { flushReasoning(true); } flushAssistant(true); pushProgressLog(t('answerGenerationCompleted')); const messageArtifacts = normalizeArtifacts(payload.artifacts); const messageCitations = normalizeKnowledgeCitations(payload.kb_citations); setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: typeof payload.content === "string" ? payload.content : msg.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz, usage: payload.usage, artifacts: messageArtifacts.length > 0 ? messageArtifacts : msg.artifacts, kbCitations: messageCitations.length > 0 ? messageCitations : msg.kbCitations } : msg ) ); } if (payload.type === "done") { hasDonePayload = true; } if (payload.type === "error") { throw new Error(payload.content || t('streamResponseError')); } if (payload.type === "viz") { if (payload.chart?.chart_spec) { pushProgressLog(t('chartGenerationCompleted')); } else if (payload.sql) { pushProgressLog(t('dataQueryCompleted')); } streamedViz = buildMessageViz(payload); flushAssistant(true); // 立即把 viz 状态刷入 messages } } } flushReasoning(true); flushAssistant(true); if (!streamedText && (hasFinalPayload || hasDonePayload)) { setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: t('noReply'), awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg ) ); } } catch (error: unknown) { const errorMessage = getErrorMessage(error); if (errorMessage.toLowerCase().includes("aborted")) { setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.awaitingFirstToken ? { ...msg, awaitingFirstToken: false, content: msg.content || t('outputInterrupted') } : msg ) ); return; } setMessagesForSession(targetSessionKey, prev => [...prev, { id: (Date.now() + 1).toString(), role: 'assistant', content: `Sorry, something went wrong: ${errorMessage}` }]); } finally { if (abortControllersRef.current[targetSessionKey] === controller) { delete abortControllersRef.current[targetSessionKey]; } generatingSessionsRef.current[targetSessionKey] = false; setIsLoadingForSession(targetSessionKey, false); window.dispatchEvent(new Event("nanobot:sessions-changed")); } }; return (