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, Search, ArrowUp, ChevronDown, Table, Paperclip, Check, X, File as FileIcon } from "lucide-react"; import { api } from "@/lib/api"; import { type ChartSpec, useVisualizationStore } 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"; interface Message { id: string; role: 'user' | 'assistant'; content: string; awaitingFirstToken?: boolean; } interface ModelConfig { id: string; name?: string; model: string; provider: string; is_active: boolean; } interface SessionData { key: string; messages: Array<{ role: string; content: string; [key: string]: any; }>; } export function ChatInterface() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [selectedCapability, setSelectedCapability] = useState("智能问答"); const [selectedDataSource, setSelectedDataSource] = useState("postgres-main"); const [isLoading, setIsLoading] = useState(false); const scrollRef = useRef(null); const { setVisualization, setLoading: setVizLoading, setError: setVizError } = useVisualizationStore(); const location = useLocation(); // Model selection state const [models, setModels] = useState([]); const [selectedModelId, setSelectedModelId] = useState(""); const [modelOpen, setModelOpen] = useState(false); // 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<{ filename: string; url: string; columns?: string[]; summary?: string } | null>(null); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); useEffect(() => { fetchModels(); }, []); useEffect(() => { const fetchSessionData = async () => { setIsLoading(true); 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 })); setMessages(formattedMessages); } else { setMessages([]); } } catch (e) { console.error("Failed to fetch session messages", e); setMessages([]); } 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 capabilities = [ { icon: Sparkles, label: "智能问答", color: "text-purple-500", bg: "bg-purple-50" }, { icon: Table, label: "表格问答", color: "text-orange-500", bg: "bg-orange-50" }, { icon: Search, label: "深度问数", color: "text-blue-500", bg: "bg-blue-50" }, ]; const chartIntentPattern = /(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)/i; 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(); setAttachedFile({ filename: file.name, url: data.url, columns: data.columns, summary: data.summary, }); } catch (error) { console.error("File upload error:", error); // Could show a toast notification here } finally { setIsUploading(false); if (fileInputRef.current) { fileInputRef.current.value = ""; } } }; useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages]); 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); } setIsLoading(true); setVizLoading(true); setVizError(null); try { if (selectedCapability === "智能问答") { 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 || ""; const source = currentAttachedFile?.url?.startsWith("local://") ? "upload" : selectedDataSource.split('-')[0]; const fileUrl = currentAttachedFile?.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, source, prefer_sql_chart: preferSqlChart, file_url: fileUrl, }), }); 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 = ""; 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 } : msg ) ); } if (payload.type === "error") { throw new Error(payload.content || "流式响应错误"); } if (payload.type === "viz") { if (payload.error) { setVizError(payload.error); } else { const rows = Array.isArray(payload.result) ? payload.result : []; const sql = typeof payload.sql === "string" ? payload.sql : ""; const chart = payload.chart ?? undefined; const canVisualize = Boolean(chart?.can_visualize); const chartSpec = canVisualize ? (chart?.chart_spec ?? null) : null; setVisualization( rows, sql, chartSpec, { canVisualize, reasoning: chart?.reasoning, chartType: chart?.chart_type, description: canVisualize ? "根据模型返回的 Vega-Lite schema 渲染" : "当前结果不适合可视化", } ); } } } } 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, source, prefer_sql_chart: preferSqlChart, file_url: fileUrl, }); if (fallback.viz?.error) { setVizError(fallback.viz.error); } else if (fallback.viz) { const rows = Array.isArray(fallback.viz.result) ? fallback.viz.result : []; const sql = typeof fallback.viz.sql === "string" ? fallback.viz.sql : ""; const chart = fallback.viz.chart ?? undefined; const canVisualize = Boolean(chart?.can_visualize); const chartSpec = canVisualize ? (chart?.chart_spec ?? null) : null; setVisualization( rows, sql, chartSpec, { canVisualize, reasoning: chart?.reasoning, chartType: chart?.chart_type, description: canVisualize ? "根据模型返回的 Vega-Lite schema 渲染" : "当前结果不适合可视化", } ); } setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: fallback.response || "暂无回复", awaitingFirstToken: false } : msg ) ); } } else { // Fallback to existing NL2SQL or other skills (e.g. for "表格问答" or "深度问数") const source = currentAttachedFile?.url?.startsWith("local://") ? "upload" : selectedDataSource.split('-')[0]; const response = await api.post<{ sql?: string, result?: unknown, error?: string, chart?: { chart_spec?: ChartSpec | null, reasoning?: string, can_visualize?: boolean, chart_type?: string } }>('/api/v1/agent/nl2sql', { query: messagePayload, source: source, file_url: currentAttachedFile?.url, session_id: activeSessionKey, model_id: selectedModelId }); if (response.error) { setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'assistant', content: `Error: ${response.error}` }]); setVizError(response.error); } else { const rows = Array.isArray(response.result) ? response.result : []; const sql = typeof response.sql === "string" ? response.sql : ""; const chart = response.chart; const canVisualize = Boolean(chart?.can_visualize); const chartSpec = canVisualize ? (chart?.chart_spec ?? null) : null; setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'assistant', content: `已为你生成 SQL 并查询到 ${rows.length} 行数据。${canVisualize ? '可视化面板已同步更新图表。' : '本次结果不适合图表展示。'}${chart?.reasoning ? `\n\n可视化说明:${chart.reasoning}` : ''}` }]); setVisualization( rows, sql, chartSpec, { canVisualize, reasoning: chart?.reasoning, chartType: chart?.chart_type, description: canVisualize ? "根据模型返回的 Vega-Lite schema 渲染" : "当前结果不适合可视化", } ); } } } catch (error: any) { setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'assistant', content: `Sorry, something went wrong: ${error.message}` }]); setVizError(error.message); } finally { setIsLoading(false); setVizLoading(false); window.dispatchEvent(new Event("nanobot:sessions-changed")); } }; return (
{/* Top Bar */}
{currentModel ? (currentModel.name || currentModel.model) : "选择模型..."} 未找到模型 {models.map((model) => ( { setSelectedModelId(model.id); setModelOpen(false); }} className="cursor-pointer" >
{model.name || model.model} {model.provider}
))}
数据源
{/* Hidden file input available in all states */}
{messages.length <= 1 ? (
{/* Logo Area */}
🦞

DataClaw

{/* Input Area */}
{attachedFile && (
{attachedFile.filename}
)}