import { useState, useRef, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { User, Loader2, Sparkles, ArrowUp, ChevronDown, Paperclip, Check, X, Square, Plus, Database, Wand2, Search, Zap, LayoutGrid, CheckCircle2, Table, XCircle, Settings, ExternalLink } 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 { InlineVisualizationCard } from "./InlineVisualizationCard"; import { useProjectStore } from "@/store/projectStore"; import { SlashCommandMenu } from "./SlashCommandMenu"; interface Message { id: string; role: 'user' | 'assistant'; content: string; awaitingFirstToken?: boolean; viz?: MessageViz; progressLogs?: string[]; routeInfo?: string; } interface MessageViz { sql: string; rows: unknown[]; chartSpec: ChartSpec | null; canVisualize: boolean; reasoning?: string; error?: string | null; } 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 SessionData { key: string; metadata?: { active_data_file?: DataFileContext | null; selected_data_source?: string | null; [key: string]: any; }; messages: Array<{ role: string; content: string; [key: string]: any; }>; } export function ChatInterface() { const [messagesBySession, setMessagesBySession] = useState>({}); const [input, setInput] = useState(""); const [selectedDataSource, setSelectedDataSource] = useState(""); const [availableSkills, setAvailableSkills] = useState([]); const [selectedSkillIds, setSelectedSkillIds] = useState([]); const [isMenuOpen, setIsMenuOpen] = useState(false); 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 [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); useEffect(() => { fetchModels(); }, []); useEffect(() => { if (currentProject) { fetchDataSources(); } }, [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 syncSessionContext = async (payload: { active_data_file?: DataFileContext | null; selected_data_source?: string | null; }) => { try { await api.put(`/nanobot/sessions/${encodeURIComponent(activeSessionKey)}/context-file`, payload); } catch (e) { console.error("Failed to sync session context", e); } }; 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, }; }); setMessagesForSession(activeSessionKey, formattedMessages); } else { setMessagesForSession(activeSessionKey, []); } const restoredFile = data.metadata?.active_data_file || null; const restoredSource = data.metadata?.selected_data_source || ""; setActiveDataFile(restoredFile); setSelectedDataSource(restoredSource); setAttachedFile(null); } catch (e) { console.error("Failed to fetch session messages", e); setMessagesForSession(activeSessionKey, []); setActiveDataFile(null); setSelectedDataSource(""); 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 = /(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)/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 selectedSkills = availableSkills.filter(skill => selectedSkillIds.includes(skill.id)); const renderActiveSelections = () => { if (!selectedDataSource && selectedSkills.length === 0) return null; return (
{selectedDataSource ? (
{`数据源:${selectedDataSourceName}`}
) : null} {selectedSkills.map((skill) => (
{`Skill:${skill.name}`}
))}
); }; const renderFileCard = () => { const file = attachedFile || activeDataFile; if (!file) return null; return (
{file.filename}
电子表格
); }; useEffect(() => { const fetchSkills = async () => { try { let url = "/api/v1/skills"; if (currentProject) { url += `?project_id=${currentProject.id}`; } const skills = await api.get(url); setAvailableSkills(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 || "已中断输出" } : 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 = `[用户上传了文件: ${currentAttachedFile.filename}]\n[文件内容摘要: ${currentAttachedFile.summary || "无"}]\n[数据列: ${currentAttachedFile.columns?.join(", ") || "无"}]\n[文件下载链接: ${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: ["请求已提交,准备路由..."], }]); const pushProgressLog = (text: string) => { if (!text.trim()) return; setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => { if (msg.id !== assistantId) return msg; const current = msg.progressLogs || []; if (current[current.length - 1] === text) return msg; const next = [...current, text].slice(-8); 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, model_id: effectiveModelId, skill_ids: selectedSkillIds, source, prefer_sql_chart: preferSqlChart, file_url: fileUrl, route_mode: "auto", }), signal: controller.signal, }); if (!response.ok || !response.body) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || "流式响应失败"); } 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 = ""; 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; sql?: string; result?: unknown; error?: string; selected?: string; reason?: string; chart?: { chart_spec?: ChartSpec | null; reasoning?: string; can_visualize?: boolean; chart_type?: string } | null; }; if (payload.type === "delta" && payload.content) { streamedText = `${streamedText}${payload.content}`; flushAssistant(false); } if (payload.type === "routing") { const selected = payload.selected === "sql" ? "SQL 分析" : "通用对话"; const reason = payload.reason ? `(${payload.reason})` : ""; pushProgressLog(`路由:${selected}${reason}`); setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, routeInfo: `${selected}${reason}` } : msg ) ); } if (payload.type === "progress" && payload.content) { pushProgressLog(payload.content); } if (payload.type === "final" && payload.content) { hasFinalPayload = true; streamedText = payload.content; flushAssistant(true); pushProgressLog("回答生成完成"); setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: payload.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg ) ); } if (payload.type === "done") { hasDonePayload = true; } if (payload.type === "error") { throw new Error(payload.content || "流式响应错误"); } if (payload.type === "viz") { if (payload.chart?.chart_spec) { pushProgressLog("图表生成完成"); } else if (payload.sql) { pushProgressLog("数据查询完成"); } streamedViz = buildMessageViz(payload); flushAssistant(true); // 立即把 viz 状态刷入 messages } } } flushAssistant(true); if (!streamedText && (hasFinalPayload || hasDonePayload)) { setMessagesForSession(targetSessionKey, (prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: "暂无回复", 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 || "已中断输出" } : 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'} 未找到模型 {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 */}
数据源
{availableDataSources.map((ds) => ( ))} {selectedDataSource && (
)}
{/* Right Column: Skills */}
Skills
{availableSkills.length > 0 ? ( availableSkills.map((skill) => { const isSelected = selectedSkillIds.includes(skill.id); return ( ); }) ) : (

暂无可用技能

)}
{selectedSkillIds.length > 0 && (
)}
setSlashQuery(null)} />
{/* Common Questions or suggestions could go here */}
) : (
{messages.map((msg) => { const { markdown, reportHtml } = splitReportHtml(msg.content); const externalReportUrl = extractExternalReport(msg.content); return (
{msg.role !== "user" && (
🦞
)}
{msg.role === "assistant" ? ( <> {msg.progressLogs && msg.progressLogs.length > 0 ? (
{msg.awaitingFirstToken ? : } {msg.awaitingFirstToken ? "正在处理中" : "处理完成"}
{msg.progressLogs.map((log, idx, arr) => { const isLast = idx === arr.length - 1; const isLoading = isLast && msg.awaitingFirstToken; return (
{isLoading ? ( ) : ( )} {log}
); })}
) : null} {msg.awaitingFirstToken && !msg.content ? (
模型思考中,请稍候...
) : ( <> {markdown ? (
{markdown}
) : null} {reportHtml ? (