2026-03-14 15:52:27 +08:00
|
|
|
|
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";
|
2026-03-15 18:41:58 +08:00
|
|
|
|
import { User, Loader2, Sparkles, Search, ArrowUp, ChevronDown, Table, Paperclip, Check, X, File as FileIcon, Square } from "lucide-react";
|
2026-03-14 15:52:27 +08:00
|
|
|
|
import { api } from "@/lib/api";
|
2026-03-15 11:07:18 +08:00
|
|
|
|
import { type ChartSpec } from "@/store/visualizationStore";
|
2026-03-14 20:58:38 +08:00
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-03-14 22:15:38 +08:00
|
|
|
|
import ReactMarkdown from 'react-markdown';
|
|
|
|
|
|
import remarkGfm from 'remark-gfm';
|
|
|
|
|
|
import rehypeRaw from 'rehype-raw';
|
2026-03-14 22:25:01 +08:00
|
|
|
|
import { useLocation } from "react-router-dom";
|
2026-03-15 11:13:40 +08:00
|
|
|
|
import { InlineVisualizationCard } from "./InlineVisualizationCard";
|
2026-03-14 15:52:27 +08:00
|
|
|
|
|
|
|
|
|
|
interface Message {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
role: 'user' | 'assistant';
|
|
|
|
|
|
content: string;
|
2026-03-14 23:15:41 +08:00
|
|
|
|
awaitingFirstToken?: boolean;
|
2026-03-15 11:07:18 +08:00
|
|
|
|
viz?: MessageViz;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface MessageViz {
|
|
|
|
|
|
sql: string;
|
|
|
|
|
|
rows: unknown[];
|
|
|
|
|
|
chartSpec: ChartSpec | null;
|
|
|
|
|
|
canVisualize: boolean;
|
|
|
|
|
|
reasoning?: string;
|
|
|
|
|
|
error?: string | null;
|
2026-03-14 15:52:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 20:58:38 +08:00
|
|
|
|
interface ModelConfig {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name?: string;
|
|
|
|
|
|
model: string;
|
|
|
|
|
|
provider: string;
|
|
|
|
|
|
is_active: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 18:25:38 +08:00
|
|
|
|
interface DataFileContext {
|
|
|
|
|
|
filename: string;
|
|
|
|
|
|
url: string;
|
|
|
|
|
|
columns?: string[];
|
|
|
|
|
|
summary?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 22:25:01 +08:00
|
|
|
|
interface SessionData {
|
|
|
|
|
|
key: string;
|
2026-03-15 18:25:38 +08:00
|
|
|
|
metadata?: {
|
|
|
|
|
|
active_data_file?: DataFileContext | null;
|
|
|
|
|
|
[key: string]: any;
|
|
|
|
|
|
};
|
2026-03-14 22:25:01 +08:00
|
|
|
|
messages: Array<{
|
|
|
|
|
|
role: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
[key: string]: any;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 15:52:27 +08:00
|
|
|
|
export function ChatInterface() {
|
2026-03-14 22:25:01 +08:00
|
|
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
2026-03-14 15:52:27 +08:00
|
|
|
|
const [input, setInput] = useState("");
|
2026-03-14 22:00:36 +08:00
|
|
|
|
const [selectedCapability, setSelectedCapability] = useState<string>("智能问答");
|
2026-03-15 10:49:37 +08:00
|
|
|
|
const [selectedDataSource, setSelectedDataSource] = useState<string>("postgres-main");
|
2026-03-14 15:52:27 +08:00
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
2026-03-14 22:25:01 +08:00
|
|
|
|
const location = useLocation();
|
2026-03-14 15:52:27 +08:00
|
|
|
|
|
2026-03-14 20:58:38 +08:00
|
|
|
|
// Model selection state
|
|
|
|
|
|
const [models, setModels] = useState<ModelConfig[]>([]);
|
|
|
|
|
|
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
|
|
|
|
|
const [modelOpen, setModelOpen] = useState(false);
|
|
|
|
|
|
|
2026-03-14 22:25:01 +08:00
|
|
|
|
// Try to parse active session from URL query
|
|
|
|
|
|
const queryParams = new URLSearchParams(location.search);
|
|
|
|
|
|
const activeSessionKey = queryParams.get("session") || "api:default";
|
|
|
|
|
|
|
2026-03-15 00:10:01 +08:00
|
|
|
|
// File upload state
|
2026-03-15 18:25:38 +08:00
|
|
|
|
const [attachedFile, setAttachedFile] = useState<DataFileContext | null>(null);
|
|
|
|
|
|
const [activeDataFile, setActiveDataFile] = useState<DataFileContext | null>(null);
|
2026-03-15 00:10:01 +08:00
|
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
2026-03-15 18:41:58 +08:00
|
|
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
2026-03-15 00:10:01 +08:00
|
|
|
|
|
2026-03-14 20:58:38 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchModels();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-15 18:25:38 +08:00
|
|
|
|
const syncSessionFileContext = async (file: DataFileContext | null) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.put(`/nanobot/sessions/${encodeURIComponent(activeSessionKey)}/context-file`, {
|
|
|
|
|
|
active_data_file: file,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("Failed to sync session file context", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-14 22:25:01 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const fetchSessionData = async () => {
|
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.get<SessionData>(`/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',
|
2026-03-15 17:57:09 +08:00
|
|
|
|
content: m.content,
|
|
|
|
|
|
viz: m.viz ? buildMessageViz(m.viz) : undefined,
|
2026-03-14 22:25:01 +08:00
|
|
|
|
}));
|
|
|
|
|
|
setMessages(formattedMessages);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setMessages([]);
|
|
|
|
|
|
}
|
2026-03-15 18:25:38 +08:00
|
|
|
|
const restoredFile = data.metadata?.active_data_file || null;
|
|
|
|
|
|
setActiveDataFile(restoredFile);
|
|
|
|
|
|
setAttachedFile(null);
|
|
|
|
|
|
if (restoredFile) {
|
|
|
|
|
|
setSelectedDataSource("upload-main");
|
|
|
|
|
|
} else if (selectedDataSource.startsWith("upload")) {
|
|
|
|
|
|
setSelectedDataSource("postgres-main");
|
|
|
|
|
|
}
|
2026-03-14 22:25:01 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("Failed to fetch session messages", e);
|
|
|
|
|
|
setMessages([]);
|
2026-03-15 18:25:38 +08:00
|
|
|
|
setActiveDataFile(null);
|
|
|
|
|
|
setAttachedFile(null);
|
2026-03-14 22:25:01 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
fetchSessionData();
|
|
|
|
|
|
}, [activeSessionKey]);
|
|
|
|
|
|
|
2026-03-14 20:58:38 +08:00
|
|
|
|
const fetchModels = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.get<ModelConfig[]>("/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);
|
|
|
|
|
|
|
2026-03-14 15:52:27 +08:00
|
|
|
|
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" },
|
|
|
|
|
|
];
|
2026-03-15 10:49:37 +08:00
|
|
|
|
const chartIntentPattern = /(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)/i;
|
2026-03-14 15:52:27 +08:00
|
|
|
|
|
2026-03-15 11:07:18 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 00:10:01 +08:00
|
|
|
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
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();
|
2026-03-15 11:07:18 +08:00
|
|
|
|
const uploadedFile = {
|
2026-03-15 00:10:01 +08:00
|
|
|
|
filename: file.name,
|
|
|
|
|
|
url: data.url,
|
|
|
|
|
|
columns: data.columns,
|
|
|
|
|
|
summary: data.summary,
|
2026-03-15 11:07:18 +08:00
|
|
|
|
};
|
|
|
|
|
|
setAttachedFile(uploadedFile);
|
|
|
|
|
|
setActiveDataFile(uploadedFile);
|
|
|
|
|
|
setSelectedDataSource("upload-main");
|
2026-03-15 18:25:38 +08:00
|
|
|
|
await syncSessionFileContext(uploadedFile);
|
2026-03-15 00:10:01 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("File upload error:", error);
|
|
|
|
|
|
// Could show a toast notification here
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsUploading(false);
|
|
|
|
|
|
if (fileInputRef.current) {
|
|
|
|
|
|
fileInputRef.current.value = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-14 15:52:27 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (scrollRef.current) {
|
|
|
|
|
|
scrollRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [messages]);
|
|
|
|
|
|
|
2026-03-15 18:41:58 +08:00
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-14 15:52:27 +08:00
|
|
|
|
const handleSend = async () => {
|
|
|
|
|
|
if (!input.trim() || isLoading) return;
|
|
|
|
|
|
|
|
|
|
|
|
const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input };
|
|
|
|
|
|
setMessages(prev => [...prev, newMessage]);
|
|
|
|
|
|
setInput("");
|
2026-03-15 00:10:01 +08:00
|
|
|
|
|
|
|
|
|
|
let messagePayload = newMessage.content;
|
2026-03-15 10:49:37 +08:00
|
|
|
|
const currentAttachedFile = attachedFile;
|
|
|
|
|
|
if (currentAttachedFile) {
|
|
|
|
|
|
messagePayload = `[用户上传了文件: ${currentAttachedFile.filename}]\n[文件内容摘要: ${currentAttachedFile.summary || "无"}]\n[数据列: ${currentAttachedFile.columns?.join(", ") || "无"}]\n[文件下载链接: ${currentAttachedFile.url}]\n\n${newMessage.content}`;
|
2026-03-15 00:10:01 +08:00
|
|
|
|
setAttachedFile(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 18:41:58 +08:00
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
abortControllerRef.current = controller;
|
2026-03-14 15:52:27 +08:00
|
|
|
|
setIsLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-14 22:00:36 +08:00
|
|
|
|
if (selectedCapability === "智能问答") {
|
|
|
|
|
|
const assistantId = (Date.now() + 1).toString();
|
|
|
|
|
|
setMessages(prev => [...prev, {
|
|
|
|
|
|
id: assistantId,
|
|
|
|
|
|
role: "assistant",
|
2026-03-14 23:15:41 +08:00
|
|
|
|
content: "",
|
|
|
|
|
|
awaitingFirstToken: true
|
2026-03-14 22:00:36 +08:00
|
|
|
|
}]);
|
|
|
|
|
|
|
|
|
|
|
|
const token = localStorage.getItem("token");
|
2026-03-14 23:15:41 +08:00
|
|
|
|
const effectiveModelId = selectedModelId || currentModel?.id || "";
|
2026-03-15 11:07:18 +08:00
|
|
|
|
const selectedSource = selectedDataSource.split('-')[0];
|
|
|
|
|
|
const useUploadSource = Boolean(
|
|
|
|
|
|
currentAttachedFile?.url?.startsWith("local://") ||
|
|
|
|
|
|
(selectedSource === "upload" && activeDataFile?.url?.startsWith("local://"))
|
|
|
|
|
|
);
|
|
|
|
|
|
const source = useUploadSource ? "upload" : selectedSource;
|
|
|
|
|
|
const fileUrl = useUploadSource ? (currentAttachedFile?.url || activeDataFile?.url) : undefined;
|
2026-03-15 10:49:37 +08:00
|
|
|
|
const preferSqlChart = chartIntentPattern.test(messagePayload);
|
2026-03-14 22:00:36 +08:00
|
|
|
|
const response = await fetch("/nanobot/chat/stream", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
2026-03-15 00:10:01 +08:00
|
|
|
|
message: messagePayload,
|
2026-03-14 22:25:01 +08:00
|
|
|
|
session_id: activeSessionKey,
|
2026-03-14 23:15:41 +08:00
|
|
|
|
model_id: effectiveModelId,
|
2026-03-15 10:49:37 +08:00
|
|
|
|
source,
|
|
|
|
|
|
prefer_sql_chart: preferSqlChart,
|
|
|
|
|
|
file_url: fileUrl,
|
2026-03-14 22:25:01 +08:00
|
|
|
|
}),
|
2026-03-15 18:41:58 +08:00
|
|
|
|
signal: controller.signal,
|
2026-03-14 22:00:36 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 = "";
|
2026-03-15 11:07:18 +08:00
|
|
|
|
let streamedViz: MessageViz | null = null;
|
2026-03-14 22:00:36 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-15 10:49:37 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-03-14 22:00:36 +08:00
|
|
|
|
|
|
|
|
|
|
if (payload.type === "delta" && payload.content) {
|
2026-03-14 22:07:40 +08:00
|
|
|
|
streamedText = `${streamedText}${payload.content}`;
|
2026-03-14 22:00:36 +08:00
|
|
|
|
setMessages((prev) =>
|
|
|
|
|
|
prev.map((msg) =>
|
2026-03-14 23:15:41 +08:00
|
|
|
|
msg.id === assistantId ? { ...msg, content: streamedText, awaitingFirstToken: false } : msg
|
2026-03-14 22:00:36 +08:00
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (payload.type === "final" && payload.content) {
|
|
|
|
|
|
streamedText = payload.content;
|
|
|
|
|
|
setMessages((prev) =>
|
|
|
|
|
|
prev.map((msg) =>
|
2026-03-15 11:07:18 +08:00
|
|
|
|
msg.id === assistantId ? { ...msg, content: payload.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
|
2026-03-14 22:00:36 +08:00
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (payload.type === "error") {
|
|
|
|
|
|
throw new Error(payload.content || "流式响应错误");
|
|
|
|
|
|
}
|
2026-03-15 10:49:37 +08:00
|
|
|
|
|
|
|
|
|
|
if (payload.type === "viz") {
|
2026-03-15 11:07:18 +08:00
|
|
|
|
streamedViz = buildMessageViz(payload);
|
|
|
|
|
|
setMessages((prev) =>
|
|
|
|
|
|
prev.map((msg) =>
|
|
|
|
|
|
msg.id === assistantId ? { ...msg, viz: streamedViz || undefined } : msg
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
2026-03-15 10:49:37 +08:00
|
|
|
|
}
|
2026-03-14 22:00:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-14 23:15:41 +08:00
|
|
|
|
|
|
|
|
|
|
if (!streamedText) {
|
2026-03-15 10:49:37 +08:00
|
|
|
|
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", {
|
2026-03-15 00:10:01 +08:00
|
|
|
|
message: messagePayload,
|
2026-03-14 23:15:41 +08:00
|
|
|
|
session_id: activeSessionKey,
|
|
|
|
|
|
model_id: effectiveModelId,
|
2026-03-15 10:49:37 +08:00
|
|
|
|
source,
|
|
|
|
|
|
prefer_sql_chart: preferSqlChart,
|
|
|
|
|
|
file_url: fileUrl,
|
2026-03-15 18:41:58 +08:00
|
|
|
|
}, { signal: controller.signal });
|
2026-03-15 11:07:18 +08:00
|
|
|
|
const fallbackViz = fallback.viz ? buildMessageViz(fallback.viz) : undefined;
|
2026-03-14 23:15:41 +08:00
|
|
|
|
setMessages((prev) =>
|
|
|
|
|
|
prev.map((msg) =>
|
2026-03-15 11:07:18 +08:00
|
|
|
|
msg.id === assistantId ? { ...msg, content: fallback.response || "暂无回复", awaitingFirstToken: false, viz: fallbackViz } : msg
|
2026-03-14 23:15:41 +08:00
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-14 22:00:36 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback to existing NL2SQL or other skills (e.g. for "表格问答" or "深度问数")
|
2026-03-15 11:07:18 +08:00
|
|
|
|
const selectedSource = selectedDataSource.split('-')[0];
|
|
|
|
|
|
const useUploadSource = Boolean(
|
|
|
|
|
|
currentAttachedFile?.url?.startsWith("local://") ||
|
|
|
|
|
|
(selectedSource === "upload" && activeDataFile?.url?.startsWith("local://"))
|
|
|
|
|
|
);
|
|
|
|
|
|
const source = useUploadSource ? "upload" : selectedSource;
|
|
|
|
|
|
const fileUrl = useUploadSource ? (currentAttachedFile?.url || activeDataFile?.url) : undefined;
|
2026-03-15 01:29:36 +08:00
|
|
|
|
const response = await api.post<{
|
|
|
|
|
|
sql?: string,
|
|
|
|
|
|
result?: unknown,
|
|
|
|
|
|
error?: string,
|
2026-03-15 10:49:37 +08:00
|
|
|
|
chart?: { chart_spec?: ChartSpec | null, reasoning?: string, can_visualize?: boolean, chart_type?: string }
|
2026-03-15 01:29:36 +08:00
|
|
|
|
}>('/api/v1/agent/nl2sql', {
|
2026-03-15 00:10:01 +08:00
|
|
|
|
query: messagePayload,
|
2026-03-14 20:58:38 +08:00
|
|
|
|
source: source,
|
2026-03-15 11:07:18 +08:00
|
|
|
|
file_url: fileUrl,
|
2026-03-14 22:25:01 +08:00
|
|
|
|
session_id: activeSessionKey,
|
2026-03-14 22:00:36 +08:00
|
|
|
|
model_id: selectedModelId
|
2026-03-15 18:41:58 +08:00
|
|
|
|
}, { signal: controller.signal });
|
2026-03-14 15:52:27 +08:00
|
|
|
|
|
|
|
|
|
|
if (response.error) {
|
|
|
|
|
|
setMessages(prev => [...prev, {
|
|
|
|
|
|
id: (Date.now() + 1).toString(),
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: `Error: ${response.error}`
|
|
|
|
|
|
}]);
|
|
|
|
|
|
} else {
|
2026-03-15 11:07:18 +08:00
|
|
|
|
const canVisualize = Boolean(response.chart?.can_visualize);
|
|
|
|
|
|
const viz = buildMessageViz({
|
|
|
|
|
|
sql: response.sql,
|
|
|
|
|
|
result: response.result,
|
|
|
|
|
|
chart: response.chart,
|
|
|
|
|
|
});
|
2026-03-14 15:52:27 +08:00
|
|
|
|
setMessages(prev => [...prev, {
|
|
|
|
|
|
id: (Date.now() + 1).toString(),
|
|
|
|
|
|
role: 'assistant',
|
2026-03-15 11:07:18 +08:00
|
|
|
|
content: `已为你生成 SQL 并查询到 ${viz.rows.length} 行数据。${canVisualize ? '图表已附在回答下方。' : '本次结果不适合图表展示。'}${response.chart?.reasoning ? `\n\n可视化说明:${response.chart.reasoning}` : ''}`,
|
|
|
|
|
|
viz,
|
2026-03-14 15:52:27 +08:00
|
|
|
|
}]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
2026-03-15 18:41:58 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
setMessages(prev => [...prev, {
|
|
|
|
|
|
id: (Date.now() + 1).toString(),
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
content: `Sorry, something went wrong: ${error.message}`
|
|
|
|
|
|
}]);
|
|
|
|
|
|
} finally {
|
2026-03-15 18:41:58 +08:00
|
|
|
|
if (abortControllerRef.current === controller) {
|
|
|
|
|
|
abortControllerRef.current = null;
|
|
|
|
|
|
}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
setIsLoading(false);
|
2026-03-14 23:15:41 +08:00
|
|
|
|
window.dispatchEvent(new Event("nanobot:sessions-changed"));
|
2026-03-14 15:52:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-15 19:07:44 +08:00
|
|
|
|
<div className="h-full min-h-0 bg-white flex flex-col">
|
2026-03-14 15:52:27 +08:00
|
|
|
|
{/* Top Bar */}
|
2026-03-15 19:07:44 +08:00
|
|
|
|
<div className="sticky top-0 left-0 w-full px-6 py-4 z-20 flex justify-between items-center bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80 border-b border-zinc-100">
|
2026-03-14 20:58:38 +08:00
|
|
|
|
<Popover open={modelOpen} onOpenChange={setModelOpen}>
|
2026-03-14 22:28:25 +08:00
|
|
|
|
<PopoverTrigger className="w-[200px] flex justify-between items-center bg-white/80 backdrop-blur-sm rounded-md px-3 py-2 text-sm hover:bg-zinc-50 hover:text-zinc-900 text-zinc-700 font-medium transition-all outline-none border-none shadow-none ring-0">
|
2026-03-14 20:58:38 +08:00
|
|
|
|
{currentModel ? (currentModel.name || currentModel.model) : "选择模型..."}
|
|
|
|
|
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="w-[240px] p-0" align="start">
|
|
|
|
|
|
<Command>
|
|
|
|
|
|
<CommandInput placeholder="搜索模型..." className="h-9" />
|
|
|
|
|
|
<CommandList>
|
|
|
|
|
|
<CommandEmpty>未找到模型</CommandEmpty>
|
|
|
|
|
|
<CommandGroup heading="可用模型">
|
|
|
|
|
|
{models.map((model) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={model.id}
|
|
|
|
|
|
value={model.name || model.model}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setSelectedModelId(model.id);
|
|
|
|
|
|
setModelOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="cursor-pointer"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span className="font-medium">{model.name || model.model}</span>
|
|
|
|
|
|
<span className="text-xs text-zinc-400">{model.provider}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"ml-auto h-4 w-4",
|
|
|
|
|
|
selectedModelId === model.id ? "opacity-100" : "opacity-0"
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
2026-03-15 10:49:37 +08:00
|
|
|
|
<div className="flex items-center gap-2 bg-white/80 backdrop-blur-sm rounded-md px-3 py-2 text-sm text-zinc-700">
|
|
|
|
|
|
<span className="text-zinc-500">数据源</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={selectedDataSource}
|
|
|
|
|
|
onChange={(e) => setSelectedDataSource(e.target.value)}
|
|
|
|
|
|
className="bg-transparent border-none outline-none text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="postgres-main">PostgreSQL</option>
|
|
|
|
|
|
<option value="clickhouse-main">ClickHouse</option>
|
2026-03-15 11:07:18 +08:00
|
|
|
|
{activeDataFile?.url?.startsWith("local://") ? (
|
|
|
|
|
|
<option value="upload-main">上传文件</option>
|
|
|
|
|
|
) : null}
|
2026-03-15 10:49:37 +08:00
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-03-14 15:52:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 19:07:44 +08:00
|
|
|
|
<ScrollArea className="flex-1 min-h-0">
|
2026-03-15 00:10:01 +08:00
|
|
|
|
{/* Hidden file input available in all states */}
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
|
className="hidden"
|
|
|
|
|
|
accept=".csv,.xls,.xlsx"
|
|
|
|
|
|
onChange={handleFileUpload}
|
|
|
|
|
|
/>
|
2026-03-14 15:52:27 +08:00
|
|
|
|
<div className="min-h-full">
|
2026-03-15 17:57:09 +08:00
|
|
|
|
{messages.length === 0 ? (
|
2026-03-14 15:52:27 +08:00
|
|
|
|
<div className="h-full flex flex-col items-center justify-center pt-[20vh] px-4 pb-32">
|
|
|
|
|
|
{/* Logo Area */}
|
|
|
|
|
|
<div className="mb-16 flex items-center justify-center gap-4 select-none">
|
|
|
|
|
|
<div className="text-[64px] leading-none animate-bounce-slow pb-3">
|
|
|
|
|
|
🦞
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h1 className="text-[56px] font-bold bg-clip-text text-transparent bg-gradient-to-r from-red-500 via-orange-500 to-amber-500 tracking-tight">
|
|
|
|
|
|
DataClaw
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Input Area */}
|
|
|
|
|
|
<div className="w-full max-w-3xl relative">
|
|
|
|
|
|
<div className="bg-white rounded-3xl shadow-[0_8px_30px_rgb(0,0,0,0.06)] border border-zinc-100 p-4 transition-shadow hover:shadow-[0_8px_40px_rgb(0,0,0,0.08)]">
|
2026-03-15 18:25:38 +08:00
|
|
|
|
{activeDataFile && (
|
2026-03-15 00:10:01 +08:00
|
|
|
|
<div className="mx-2 mb-3 p-2.5 bg-blue-50/50 border border-blue-100/50 rounded-xl flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-2.5 text-sm text-blue-900">
|
|
|
|
|
|
<div className="p-1.5 bg-blue-100 rounded-md">
|
|
|
|
|
|
<FileIcon className="h-4 w-4 text-blue-600" />
|
|
|
|
|
|
</div>
|
2026-03-15 18:25:38 +08:00
|
|
|
|
<span className="font-medium truncate max-w-[300px]">{activeDataFile.filename}</span>
|
2026-03-15 00:10:01 +08:00
|
|
|
|
</div>
|
2026-03-15 18:25:38 +08:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
setAttachedFile(null);
|
|
|
|
|
|
setActiveDataFile(null);
|
|
|
|
|
|
if (selectedDataSource.startsWith("upload")) {
|
|
|
|
|
|
setSelectedDataSource("postgres-main");
|
|
|
|
|
|
}
|
|
|
|
|
|
await syncSessionFileContext(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="p-1 text-blue-400 hover:text-blue-600 hover:bg-blue-100/50 rounded-md transition-colors"
|
|
|
|
|
|
>
|
2026-03-15 00:10:01 +08:00
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
<textarea
|
|
|
|
|
|
className="w-full min-h-[60px] max-h-[200px] resize-none border-none focus:ring-0 text-lg text-zinc-700 placeholder:text-zinc-300 bg-transparent p-2"
|
|
|
|
|
|
placeholder="先思考后回答,解决更有难度的问题"
|
|
|
|
|
|
value={input}
|
|
|
|
|
|
onChange={(e) => setInput(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
handleSend();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between mt-4 pt-2 border-t border-zinc-50">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{capabilities.map((cap) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={cap.label}
|
2026-03-14 22:00:36 +08:00
|
|
|
|
onClick={() => setSelectedCapability(cap.label)}
|
|
|
|
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
|
|
|
|
|
selectedCapability === cap.label
|
|
|
|
|
|
? `${cap.bg} ${cap.color} ring-1 ring-${cap.color.split('-')[1]}-200 shadow-sm`
|
|
|
|
|
|
: 'bg-zinc-50 text-zinc-500 hover:bg-zinc-100'
|
|
|
|
|
|
}`}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
>
|
|
|
|
|
|
<cap.icon className="h-3.5 w-3.5" />
|
|
|
|
|
|
{cap.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
2026-03-15 00:10:01 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-9 w-9 text-zinc-400 hover:text-zinc-600 rounded-full"
|
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
|
disabled={isUploading}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isUploading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Paperclip className="h-5 w-5" />}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-03-15 18:41:58 +08:00
|
|
|
|
onClick={isLoading ? handleForceStop : handleSend}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
size="icon"
|
2026-03-15 18:41:58 +08:00
|
|
|
|
disabled={isLoading ? false : !input.trim()}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
className={`h-9 w-9 rounded-full transition-all ${
|
2026-03-15 18:41:58 +08:00
|
|
|
|
isLoading
|
|
|
|
|
|
? 'bg-red-100 text-red-700 hover:bg-red-200'
|
|
|
|
|
|
: input.trim()
|
|
|
|
|
|
? 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200'
|
|
|
|
|
|
: 'bg-zinc-50 text-zinc-300 cursor-not-allowed'
|
2026-03-14 15:52:27 +08:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-03-15 18:41:58 +08:00
|
|
|
|
{isLoading ? <Square className="h-4 w-4" /> : <ArrowUp className="h-5 w-5" />}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-03-15 19:07:44 +08:00
|
|
|
|
<div className="max-w-3xl mx-auto px-4 py-8 space-y-8">
|
2026-03-14 15:52:27 +08:00
|
|
|
|
{messages.map((msg) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={msg.id}
|
|
|
|
|
|
className={`flex gap-4 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{msg.role !== "user" && (
|
|
|
|
|
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center text-white shrink-0 mt-1 shadow-sm">
|
|
|
|
|
|
<span className="font-bold text-xs">Ai</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`rounded-2xl px-5 py-3.5 text-[15px] leading-relaxed max-w-[85%] shadow-sm ${
|
|
|
|
|
|
msg.role === "user"
|
|
|
|
|
|
? "bg-zinc-100 text-zinc-800"
|
2026-03-14 22:15:38 +08:00
|
|
|
|
: "bg-white border border-zinc-100 text-zinc-700 overflow-hidden"
|
2026-03-14 15:52:27 +08:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-03-14 22:15:38 +08:00
|
|
|
|
{msg.role === "assistant" ? (
|
2026-03-14 23:15:41 +08:00
|
|
|
|
msg.awaitingFirstToken && !msg.content ? (
|
|
|
|
|
|
<div className="flex items-center gap-2 text-zinc-500 text-sm py-1">
|
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
|
<span>模型思考中,请稍候...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-03-15 11:07:18 +08:00
|
|
|
|
<>
|
|
|
|
|
|
<div className="prose prose-sm prose-zinc max-w-none prose-p:leading-normal prose-p:my-2 prose-headings:my-3 prose-ul:my-2 prose-li:my-0.5 prose-pre:bg-zinc-50 prose-pre:text-zinc-800 prose-pre:border prose-pre:border-zinc-200">
|
|
|
|
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
|
|
|
|
|
{msg.content}
|
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{msg.viz ? (
|
|
|
|
|
|
<div className="mt-3 pt-3 border-t border-zinc-100">
|
2026-03-15 11:13:40 +08:00
|
|
|
|
<InlineVisualizationCard viz={msg.viz} />
|
2026-03-15 11:07:18 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
2026-03-14 23:15:41 +08:00
|
|
|
|
)
|
2026-03-14 22:15:38 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
msg.content
|
|
|
|
|
|
)}
|
2026-03-14 15:52:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
{msg.role === "user" && (
|
|
|
|
|
|
<div className="w-8 h-8 rounded-full bg-zinc-200 flex items-center justify-center text-zinc-500 shrink-0 mt-1">
|
|
|
|
|
|
<User className="h-4 w-4" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<div ref={scrollRef} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Floating Input for Chat State */}
|
|
|
|
|
|
{messages.length > 1 && (
|
2026-03-15 19:07:44 +08:00
|
|
|
|
<div className="px-4 pb-6 pt-3 border-t border-zinc-100 bg-white">
|
2026-03-14 15:52:27 +08:00
|
|
|
|
<div className="max-w-3xl mx-auto">
|
2026-03-15 00:10:01 +08:00
|
|
|
|
<div className="bg-white rounded-2xl shadow-xl border border-zinc-200/60 p-2 flex flex-col gap-2 ring-1 ring-zinc-100">
|
2026-03-15 18:25:38 +08:00
|
|
|
|
{activeDataFile && (
|
2026-03-15 00:10:01 +08:00
|
|
|
|
<div className="mx-2 mt-1 p-2 bg-blue-50/50 border border-blue-100/50 rounded-lg flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-2 text-xs text-blue-900">
|
|
|
|
|
|
<FileIcon className="h-3.5 w-3.5 text-blue-600" />
|
2026-03-15 18:25:38 +08:00
|
|
|
|
<span className="font-medium truncate max-w-[200px]">{activeDataFile.filename}</span>
|
2026-03-15 00:10:01 +08:00
|
|
|
|
</div>
|
2026-03-15 18:25:38 +08:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
setAttachedFile(null);
|
|
|
|
|
|
setActiveDataFile(null);
|
|
|
|
|
|
if (selectedDataSource.startsWith("upload")) {
|
|
|
|
|
|
setSelectedDataSource("postgres-main");
|
|
|
|
|
|
}
|
|
|
|
|
|
await syncSessionFileContext(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-blue-400 hover:text-blue-600"
|
|
|
|
|
|
>
|
2026-03-15 00:10:01 +08:00
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="h-9 w-9 text-zinc-400 hover:text-zinc-600 rounded-full shrink-0"
|
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
|
disabled={isUploading || isLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isUploading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Paperclip className="h-5 w-5" />}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
className="flex-1 border-none shadow-none focus-visible:ring-0 text-base text-zinc-700 placeholder:text-zinc-400 h-11 bg-transparent"
|
|
|
|
|
|
placeholder="Send a message..."
|
|
|
|
|
|
value={input}
|
|
|
|
|
|
onChange={(e) => setInput(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
|
|
|
|
|
disabled={isLoading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
2026-03-15 18:41:58 +08:00
|
|
|
|
onClick={isLoading ? handleForceStop : handleSend}
|
2026-03-15 00:10:01 +08:00
|
|
|
|
size="icon"
|
2026-03-15 18:41:58 +08:00
|
|
|
|
disabled={isLoading ? false : !input.trim()}
|
2026-03-15 00:10:01 +08:00
|
|
|
|
className={`h-9 w-9 rounded-lg shrink-0 transition-all ${
|
2026-03-15 18:41:58 +08:00
|
|
|
|
isLoading
|
|
|
|
|
|
? 'bg-red-600 hover:bg-red-700 text-white shadow-md'
|
|
|
|
|
|
: input.trim()
|
|
|
|
|
|
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-md'
|
|
|
|
|
|
: 'bg-zinc-100 text-zinc-300 hover:bg-zinc-100 cursor-not-allowed'
|
2026-03-15 00:10:01 +08:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-03-15 18:41:58 +08:00
|
|
|
|
{isLoading ? <Square className="h-4 w-4" /> : <ArrowUp className="h-5 w-5" />}
|
2026-03-15 00:10:01 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-03-14 15:52:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|