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 } 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"; interface Message { id: string; role: 'user' | 'assistant'; content: string; awaitingFirstToken?: boolean; viz?: MessageViz; } interface MessageViz { sql: string; rows: unknown[]; chartSpec: ChartSpec | null; canVisualize: boolean; reasoning?: string; error?: string | 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 [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [selectedDataSource, setSelectedDataSource] = useState(""); const [availableSkills, setAvailableSkills] = useState([]); const [selectedSkillIds, setSelectedSkillIds] = useState([]); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const scrollRef = useRef(null); const location = useLocation(); const { currentProject } = useProjectStore(); // 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}[]>([]); // Try to parse active session from URL query const queryParams = new URLSearchParams(location.search); const activeSessionKey = queryParams.get("session") || "api:default"; // File upload state const [attachedFile, setAttachedFile] = useState(null); const [activeDataFile, setActiveDataFile] = useState(null); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); const abortControllerRef = 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 () => { setIsLoading(true); setSelectedSkillIds([]); try { const data = await api.get(`/nanobot/sessions/${activeSessionKey}`); if (data.messages && data.messages.length > 0) { const formattedMessages = data.messages.map((m, idx) => ({ id: `${Date.now()}-${idx}`, role: m.role as 'user' | 'assistant', content: m.content, viz: m.viz ? buildMessageViz(m.viz) : undefined, })); setMessages(formattedMessages); } else { setMessages([]); } 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); setMessages([]); setActiveDataFile(null); setSelectedDataSource(""); setAttachedFile(null); } finally { setIsLoading(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 = Boolean(chart?.can_visualize); const chartSpec = canVisualize ? (chart?.chart_spec ?? null) : 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 = abortControllerRef.current; if (!controller) return; controller.abort(); setIsLoading(false); setMessages((prev) => prev.map((msg) => msg.awaitingFirstToken ? { ...msg, awaitingFirstToken: false, content: msg.content || "已中断输出" } : msg ) ); }; const handleSend = async () => { if (!input.trim() || isLoading) return; const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input }; setMessages(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(); abortControllerRef.current = controller; setIsLoading(true); try { const assistantId = (Date.now() + 1).toString(); setMessages(prev => [...prev, { id: assistantId, role: "assistant", content: "", awaitingFirstToken: true }]); 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: activeSessionKey, 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; 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; chart?: { chart_spec?: ChartSpec | null; reasoning?: string; can_visualize?: boolean; chart_type?: string } | null; }; if (payload.type === "delta" && payload.content) { streamedText = `${streamedText}${payload.content}`; setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: streamedText, awaitingFirstToken: false } : msg ) ); } if (payload.type === "final" && payload.content) { streamedText = payload.content; setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: payload.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg ) ); } if (payload.type === "error") { throw new Error(payload.content || "流式响应错误"); } if (payload.type === "viz") { streamedViz = buildMessageViz(payload); setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, viz: streamedViz || undefined } : msg ) ); } } } if (!streamedText) { const fallback = await api.post<{ response: string; viz?: { sql?: string; result?: unknown; error?: string | null; chart?: { chart_spec?: ChartSpec | null; reasoning?: string; can_visualize?: boolean; chart_type?: string } | null; }; }>("/nanobot/chat", { message: messagePayload, session_id: activeSessionKey, model_id: effectiveModelId, skill_ids: selectedSkillIds, source, prefer_sql_chart: preferSqlChart, file_url: fileUrl, route_mode: "auto", }, { signal: controller.signal }); const fallbackViz = fallback.viz ? buildMessageViz(fallback.viz) : undefined; setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: fallback.response || "暂无回复", awaitingFirstToken: false, viz: fallbackViz } : msg ) ); } } catch (error: any) { if (error?.name === "AbortError" || String(error?.message || "").toLowerCase().includes("aborted")) { setMessages((prev) => prev.map((msg) => msg.awaitingFirstToken ? { ...msg, awaitingFirstToken: false, content: msg.content || "已中断输出" } : msg ) ); return; } setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'assistant', content: `Sorry, something went wrong: ${error.message}` }]); } finally { if (abortControllerRef.current === controller) { abortControllerRef.current = null; } setIsLoading(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 && (
)}
setInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleSend()} placeholder="有问题,尽管问" className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none" disabled={isLoading} />
{/* Common Questions or suggestions could go here */}
) : (
{messages.map((msg) => (
{msg.role !== "user" && (
🦞
)}
{msg.role === "assistant" ? ( msg.awaitingFirstToken && !msg.content ? (
模型思考中,请稍候...
) : ( <>
{msg.content}
{msg.viz ? (
) : null} ) ) : ( msg.content )}
{msg.role === "user" && (
)}
))}
)}
{/* Floating Input for Chat State */} {messages.length > 0 && (
{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 && (
)}
setInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleSend()} placeholder="有问题,尽管问" className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none" disabled={isLoading} />

DataClaw 可能会出错。请核查重要信息。

)}
); }