import { useState, useRef, useEffect } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { User, Loader2, ArrowUp, ChevronDown, Check, Square, Plus, Database, Wand2, Zap, CheckCircle2, Table, XCircle, Settings, ExternalLink, FileText, Download, Eye, Copy, Mic, X } from "lucide-react"; import { api } from "@/lib/api"; import { type ChartSpec } from "@/store/visualizationStore"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 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[]; } 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; } 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]: any; }; messages: Array<{ role: string; content: string; [key: string]: any; }>; } const formatArtifactSize = (size: number): string => { if (!Number.isFinite(size) || size < 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let value = size; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } const fixed = value >= 10 || unitIndex === 0 ? 0 : 1; return `${value.toFixed(fixed)} ${units[unitIndex]}`; }; 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; }, []); }; export function ChatInterface() { const { t } = useTranslation(); const [messagesBySession, setMessagesBySession] = useState>({}); const [input, setInput] = useState(""); const [selectedDataSource, setSelectedDataSource] = useState(""); const [selectedKnowledgeBaseId, setSelectedKnowledgeBaseId] = useState(""); const [availableSkills, setAvailableSkills] = useState([]); const [availableKnowledgeBases, setAvailableKnowledgeBases] = useState([]); const [selectedSkillIds, setSelectedSkillIds] = 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 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>({}); // Model selection state const [models, setModels] = useState([]); const [selectedModelId, setSelectedModelId] = useState(""); const [modelOpen, setModelOpen] = useState(false); // 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(); } else { setAvailableKnowledgeBases([]); setSelectedKnowledgeBaseId(""); } }, [currentProject]); 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(`/nanobot/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(`/nanobot/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') { cleanContent = cleanContent.replace(/^\[System:.*?\]\n?/i, ''); // Handle cases where there might be a runtime context block for skills cleanContent = cleanContent.replace(/\[Runtime Context[\s\S]*?(?=\[System:|$)/i, ''); cleanContent = cleanContent.replace(/\[System:.*?\]\n?/i, ''); // clean again in case it follows context 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 renderActiveSelections = () => { if (!selectedDataSource && !selectedKnowledgeBaseId && selectedSkills.length === 0) return null; return (
{selectedDataSource ? (
{`${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')}
); }; 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 controller = abortControllersRef.current[activeSessionKey]; if (!controller) return; 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); } 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("/nanobot/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(() => ({})); throw new Error(err.detail || t('streamResponseFailed')); } 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: any) { if (error?.name === "AbortError" || String(error?.message || "").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: ${error.message}` }]); } 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 (
{/* Header with Model Selection */}
{selectedModelId ? models.find(m => m.id === selectedModelId)?.name || 'DataClaw' : 'DataClaw'} {t('modelNotFound')} {models.map((model) => ( { setSelectedModelId(model.id); setModelOpen(false); }} className="flex items-center gap-2 py-2.5 cursor-pointer" >
{model.name || model.model} {model.provider}
))}
{/* Hidden file input available in all states */}
{messages.length === 0 ? (
{/* Logo Area */}
🦞

DataClaw

{/* Input Area */}
{renderFileCard()} {renderActiveSelections()}
{/* Left Column: Data Source */}
{t('dataSource')}
{availableDataSources.map((ds) => ( ))} {selectedDataSource && (
)}
{t('knowledgeBase')}
{availableKnowledgeBases.length > 0 ? ( availableKnowledgeBases.map((kb) => ( )) ) : (
{t('noKnowledgeBases')}
)} {selectedKnowledgeBaseId ? (
) : null}
{/* Right Column: Skills */}
Skills
{availableSkills.length > 0 ? ( availableSkills.map((skill) => { const isSelected = selectedSkillIds.includes(skill.id); return ( ); }) ) : (

{t('noAvailableSkills')}

)}
{selectedSkillIds.length > 0 && (
)}
{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)} />
)}
{/* Common Questions or suggestions could go here */}
) : (
{messages.map((msg, msgIdx) => { const isMessageGenerating = isLoading && msgIdx === messages.length - 1; const { markdown, reportHtml } = splitReportHtml(msg.content); const externalReportUrl = extractExternalReport(msg.content); const fallbackThinkingLines = Array.from(new Set( (msg.progressLogs || []).filter((log) => log && log !== t('requestSubmittedRouting') && log !== t('answerGenerationCompleted') ) )); const displayedThinkingContent = (msg.reasoningContent || "").trim() || fallbackThinkingLines.join("\n"); return (
{msg.role !== "user" && (
🦞
)}
{msg.role === "assistant" ? ( <> {displayedThinkingContent && (
{!isThinkingCollapsed(msg.id) && (
{displayedThinkingContent}
)}
)} {msg.progressLogs && msg.progressLogs.length > 0 ? (
{isMessageGenerating ? : } {isMessageGenerating ? t('processing') : t('processCompleted')}
{ if (el && isMessageGenerating) { el.scrollTop = el.scrollHeight; } }} > {msg.progressLogs.map((log, idx, arr) => { const isLast = idx === arr.length - 1; // 只有当是整个会话的最后一条消息,且当前日志是最后一条时,才显示 loading 动画 const isLoadingLog = isLast && isMessageGenerating; return (
{isLoadingLog ? ( ) : ( )} {log}
); })}
) : null} {msg.awaitingFirstToken && !msg.content ? (
{t('modelThinking')}
) : ( <> {markdown ? (
{markdown}
) : null} {reportHtml ? (