Files
DataClaw/frontend/src/components/ChatInterface.tsx
T

1830 lines
82 KiB
TypeScript
Raw Normal View History

2026-03-14 15:52:27 +08:00
import { useState, useRef, useEffect } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
2026-03-28 20:00:48 +08:00
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";
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-21 21:26:57 +08:00
import { useTranslation } from "react-i18next";
2026-03-15 11:13:40 +08:00
import { InlineVisualizationCard } from "./InlineVisualizationCard";
2026-03-16 16:12:35 +08:00
import { useProjectStore } from "@/store/projectStore";
2026-03-18 17:28:48 +08:00
import { SlashCommandMenu } from "./SlashCommandMenu";
2026-03-28 20:00:48 +08:00
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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;
2026-03-17 21:32:01 +08:00
progressLogs?: string[];
routeInfo?: string;
2026-03-20 16:54:21 +08:00
reasoningContent?: string;
2026-03-28 14:46:50 +08:00
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
2026-03-27 15:10:33 +08:00
artifacts?: MessageArtifact[];
2026-03-15 11:07:18 +08:00
}
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-27 15:10:33 +08:00
interface MessageArtifact {
name: string;
mime_type: string;
size: number;
download_url: string;
previewable: boolean;
preview_url?: string;
}
interface ArtifactPreviewTarget {
name: string;
mimeType: string;
previewUrl: string;
}
2026-03-18 22:42:18 +08:00
const REPORT_HTML_BLOCK_REGEX = /<!--\s*REPORT_HTML_START\s*-->([\s\S]*?)<!--\s*REPORT_HTML_END\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 };
};
2026-03-19 17:48:52 +08:00
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;
};
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-15 22:16:04 +08:00
interface Skill {
id: string;
name: string;
description?: string;
type: string;
}
2026-03-28 01:01:13 +08:00
const dedupeSkillsById = (skills: Skill[]): Skill[] => {
const map = new Map<string, Skill>();
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());
};
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;
2026-03-16 23:16:33 +08:00
selected_data_source?: string | null;
2026-03-15 18:25:38 +08:00
[key: string]: any;
};
2026-03-14 22:25:01 +08:00
messages: Array<{
role: string;
content: string;
[key: string]: any;
}>;
}
2026-03-27 15:10:33 +08:00
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<MessageArtifact[]>((acc, item) => {
if (!item || typeof item !== "object") return acc;
const source = item as Record<string, unknown>;
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;
}, []);
};
2026-03-14 15:52:27 +08:00
export function ChatInterface() {
2026-03-21 21:26:57 +08:00
const { t } = useTranslation();
2026-03-17 20:12:48 +08:00
const [messagesBySession, setMessagesBySession] = useState<Record<string, Message[]>>({});
2026-03-14 15:52:27 +08:00
const [input, setInput] = useState("");
2026-03-16 22:18:23 +08:00
const [selectedDataSource, setSelectedDataSource] = useState<string>("");
2026-03-15 22:16:04 +08:00
const [availableSkills, setAvailableSkills] = useState<Skill[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
2026-03-27 15:10:33 +08:00
const [artifactPreview, setArtifactPreview] = useState<ArtifactPreviewTarget | null>(null);
2026-03-28 14:46:50 +08:00
const [collapsedThinkingByMessage, setCollapsedThinkingByMessage] = useState<Record<string, boolean>>({});
const [thinkingCopiedByMessage, setThinkingCopiedByMessage] = useState<Record<string, boolean>>({});
2026-03-14 15:52:27 +08:00
const scrollRef = useRef<HTMLDivElement>(null);
2026-03-14 22:25:01 +08:00
const location = useLocation();
2026-03-16 16:12:35 +08:00
const { currentProject } = useProjectStore();
2026-03-14 15:52:27 +08:00
2026-03-18 17:28:48 +08:00
// Slash Command State
const [slashQuery, setSlashQuery] = useState<string | null>(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);
};
2026-03-19 14:57:42 +08:00
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Avoid triggering Enter when using IME (Input Method Editor) for CJK characters
if (e.nativeEvent.isComposing) {
return;
}
2026-03-18 17:28:48 +08:00
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<HTMLInputElement>) => {
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);
}
};
2026-03-17 20:12:48 +08:00
const setMessagesForSession = (sessionKey: string, updater: React.SetStateAction<Message[]>) => {
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<Record<string, boolean>>({});
const isLoading = loadingBySession[activeSessionKey] || false;
const generatingSessionsRef = useRef<Record<string, boolean>>({});
const abortControllersRef = useRef<Record<string, AbortController>>({});
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-15 19:36:02 +08:00
// Data Source selection state
2026-03-16 16:12:35 +08:00
const [availableDataSources, setAvailableDataSources] = useState<{id: string, name: string}[]>([]);
2026-03-14 20:58:38 +08:00
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-21 21:26:57 +08:00
const [, setIsUploading] = useState(false);
2026-03-15 00:10:01 +08:00
const fileInputRef = useRef<HTMLInputElement>(null);
2026-03-28 20:00:48 +08:00
// Speech Recognition State
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const [recordingLevel, setRecordingLevel] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const shouldTranscribeRef = useRef(true);
const audioContextRef = useRef<AudioContext | null>(null);
const audioAnimationRef = useRef<number | null>(null);
// Local storage for whisper URL
const [whisperUrl, setWhisperUrl] = useState(() => localStorage.getItem("whisper_url") || "http://localhost:8001");
const [isVoiceSettingsOpen, setIsVoiceSettingsOpen] = useState(false);
const handleSaveWhisperUrl = (url: string) => {
setWhisperUrl(url);
localStorage.setItem("whisper_url", url);
setIsVoiceSettingsOpen(false);
};
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 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 = whisperUrl || "http://localhost:8001";
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());
}
};
}, []);
2026-03-14 20:58:38 +08:00
useEffect(() => {
fetchModels();
}, []);
2026-03-16 16:12:35 +08:00
useEffect(() => {
if (currentProject) {
fetchDataSources();
}
}, [currentProject]);
2026-03-15 19:36:02 +08:00
const fetchDataSources = async () => {
2026-03-16 16:12:35 +08:00
if (!currentProject) return;
2026-03-15 19:36:02 +08:00
try {
2026-03-16 16:12:35 +08:00
const data = await api.get<Array<{id: number, name: string}>>(`/api/v1/datasources?project_id=${currentProject.id}`);
const projectSources = data.map(d => ({ id: `ds:${d.id}`, name: d.name }));
setAvailableDataSources(projectSources);
2026-03-16 22:18:23 +08:00
if (selectedDataSource && !projectSources.find(ds => ds.id === selectedDataSource)) {
setSelectedDataSource("");
2026-03-16 23:16:33 +08:00
void syncSessionContext({ selected_data_source: null });
2026-03-16 16:12:35 +08:00
}
2026-03-15 19:36:02 +08:00
} catch (e) {
console.error("Failed to fetch data sources", e);
}
};
2026-03-16 23:16:33 +08:00
const syncSessionContext = async (payload: {
active_data_file?: DataFileContext | null;
selected_data_source?: string | null;
}) => {
2026-03-15 18:25:38 +08:00
try {
2026-03-16 23:16:33 +08:00
await api.put(`/nanobot/sessions/${encodeURIComponent(activeSessionKey)}/context-file`, payload);
2026-03-15 18:25:38 +08:00
} catch (e) {
2026-03-16 23:16:33 +08:00
console.error("Failed to sync session context", e);
2026-03-15 18:25:38 +08:00
}
};
2026-03-16 23:16:33 +08:00
const handleSelectDataSource = async (sourceId: string) => {
setSelectedDataSource(sourceId);
await syncSessionContext({ selected_data_source: sourceId });
};
const handleClearDataSource = async () => {
setSelectedDataSource("");
await syncSessionContext({ selected_data_source: null });
};
2026-03-14 22:25:01 +08:00
useEffect(() => {
const fetchSessionData = async () => {
2026-03-17 20:12:48 +08:00
if (generatingSessionsRef.current[activeSessionKey]) {
return; // Do not fetch if we are currently generating for this session
}
setIsLoadingForSession(activeSessionKey, true);
2026-03-16 22:18:23 +08:00
setSelectedSkillIds([]);
2026-03-14 22:25:01 +08:00
try {
const data = await api.get<SessionData>(`/nanobot/sessions/${activeSessionKey}`);
if (data.messages && data.messages.length > 0) {
2026-03-19 16:08:20 +08:00
const formattedMessages = data.messages
.filter((m) => {
if (m.role === 'system' || m.role === 'tool' || m.role === 'function') return false;
2026-03-19 17:40:08 +08:00
if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0 && !m.viz && (!m.content || m.content.trim() === '')) return false;
2026-03-19 16:08:20 +08:00
return true;
})
2026-03-20 11:28:59 +08:00
.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,
2026-03-28 14:46:50 +08:00
reasoningContent: typeof m.reasoning_content === "string" ? m.reasoning_content : undefined,
usage: m.usage,
2026-03-27 15:10:33 +08:00
artifacts: normalizeArtifacts(m.artifacts),
2026-03-20 11:28:59 +08:00
};
});
2026-03-17 20:12:48 +08:00
setMessagesForSession(activeSessionKey, formattedMessages);
2026-03-14 22:25:01 +08:00
} else {
2026-03-17 20:12:48 +08:00
setMessagesForSession(activeSessionKey, []);
2026-03-14 22:25:01 +08:00
}
2026-03-15 18:25:38 +08:00
const restoredFile = data.metadata?.active_data_file || null;
2026-03-16 23:16:33 +08:00
const restoredSource = data.metadata?.selected_data_source || "";
2026-03-15 18:25:38 +08:00
setActiveDataFile(restoredFile);
2026-03-16 23:16:33 +08:00
setSelectedDataSource(restoredSource);
2026-03-15 18:25:38 +08:00
setAttachedFile(null);
2026-03-14 22:25:01 +08:00
} catch (e) {
console.error("Failed to fetch session messages", e);
2026-03-17 20:12:48 +08:00
setMessagesForSession(activeSessionKey, []);
2026-03-15 18:25:38 +08:00
setActiveDataFile(null);
2026-03-16 23:16:33 +08:00
setSelectedDataSource("");
2026-03-15 18:25:38 +08:00
setAttachedFile(null);
2026-03-14 22:25:01 +08:00
} finally {
2026-03-17 20:12:48 +08:00
setIsLoadingForSession(activeSessionKey, false);
2026-03-14 22:25:01 +08:00
}
};
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-21 21:26:57 +08:00
const chartIntentPattern = new RegExp(t('chartIntentPattern'), '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;
2026-03-19 12:27:31 +08:00
const canVisualize = chart?.can_visualize ?? Boolean(chart?.chart_spec);
const chartSpec = chart?.chart_spec ?? null;
2026-03-15 11:07:18 +08:00
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);
2026-03-16 22:18:23 +08:00
setSelectedDataSource("");
2026-03-16 23:16:33 +08:00
await syncSessionContext({ active_data_file: uploadedFile, selected_data_source: null });
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-15 22:16:04 +08:00
const handleRemoveFile = async () => {
setAttachedFile(null);
setActiveDataFile(null);
2026-03-16 23:16:33 +08:00
await syncSessionContext({ active_data_file: null });
2026-03-15 22:16:04 +08:00
};
2026-03-16 22:18:23 +08:00
const selectedDataSourceName = availableDataSources.find(ds => ds.id === selectedDataSource)?.name || "";
const selectedSkills = availableSkills.filter(skill => selectedSkillIds.includes(skill.id));
2026-03-28 14:46:50 +08:00
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);
}
};
2026-03-16 22:18:23 +08:00
const renderActiveSelections = () => {
if (!selectedDataSource && selectedSkills.length === 0) return null;
return (
<div className="px-2 pt-2">
<div className="flex flex-wrap gap-2">
{selectedDataSource ? (
<div className="px-3 py-1.5 rounded-full text-xs border flex items-center gap-1.5 bg-blue-50 text-blue-700 border-blue-200">
<Database className="h-3.5 w-3.5" />
2026-03-21 21:26:57 +08:00
{`${t('dataSource')}${selectedDataSourceName}`}
2026-03-16 22:18:23 +08:00
</div>
) : null}
{selectedSkills.map((skill) => (
<div
key={skill.id}
className="px-3 py-1.5 rounded-full text-xs border flex items-center gap-1.5 bg-orange-50 text-orange-700 border-orange-200"
>
<Wand2 className="h-3.5 w-3.5" />
{`Skill${skill.name}`}
</div>
))}
</div>
</div>
);
};
2026-03-15 22:16:04 +08:00
const renderFileCard = () => {
const file = attachedFile || activeDataFile;
if (!file) return null;
return (
<div className="px-2 pt-2">
2026-03-28 16:25:35 +08:00
<div className="p-2.5 bg-background border border-border rounded-2xl flex items-center gap-3 relative group/file shadow-sm max-w-[280px]">
2026-03-15 22:16:04 +08:00
<div className="h-10 w-10 bg-emerald-600 rounded-xl flex items-center justify-center shrink-0">
2026-03-28 16:25:35 +08:00
<Table className="h-6 w-6 text-primary-foreground" />
2026-03-15 22:16:04 +08:00
</div>
<div className="flex-1 min-w-0 pr-6">
2026-03-28 16:25:35 +08:00
<div className="text-sm font-bold text-foreground truncate">{file.filename}</div>
<div className="text-xs text-muted-foreground">{t('spreadsheet')}</div>
2026-03-15 22:16:04 +08:00
</div>
<button
onClick={handleRemoveFile}
className="absolute top-1.5 right-1.5 h-5 w-5 rounded-full flex items-center justify-center transition-colors group/close"
>
2026-03-28 16:25:35 +08:00
<XCircle className="h-5 w-5 fill-zinc-900 text-primary-foreground" />
2026-03-15 22:16:04 +08:00
</button>
</div>
</div>
);
};
useEffect(() => {
const fetchSkills = async () => {
try {
2026-03-16 16:12:35 +08:00
let url = "/api/v1/skills";
if (currentProject) {
url += `?project_id=${currentProject.id}`;
}
const skills = await api.get<Skill[]>(url);
2026-03-28 01:01:13 +08:00
setAvailableSkills(dedupeSkillsById(skills || []));
2026-03-15 22:16:04 +08:00
} catch (err) {
console.error("Failed to fetch skills:", err);
}
};
fetchSkills();
2026-03-16 16:12:35 +08:00
}, [currentProject]);
2026-03-15 22:16:04 +08:00
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 = () => {
2026-03-17 20:12:48 +08:00
const controller = abortControllersRef.current[activeSessionKey];
2026-03-15 18:41:58 +08:00
if (!controller) return;
controller.abort();
2026-03-17 20:12:48 +08:00
setIsLoadingForSession(activeSessionKey, false);
generatingSessionsRef.current[activeSessionKey] = false;
setMessagesForSession(activeSessionKey, (prev) =>
2026-03-15 18:41:58 +08:00
prev.map((msg) =>
msg.awaitingFirstToken
2026-03-21 21:26:57 +08:00
? { ...msg, awaitingFirstToken: false, content: msg.content || t('outputInterrupted') }
2026-03-15 18:41:58 +08:00
: msg
)
);
};
2026-03-14 15:52:27 +08:00
const handleSend = async () => {
2026-03-17 11:38:02 +08:00
if (!input.trim() || isLoading) return;
2026-03-14 15:52:27 +08:00
2026-03-17 20:12:48 +08:00
const targetSessionKey = activeSessionKey;
2026-03-14 15:52:27 +08:00
const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input };
2026-03-17 20:12:48 +08:00
setMessagesForSession(targetSessionKey, prev => [...prev, newMessage]);
2026-03-14 15:52:27 +08:00
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) {
2026-03-21 21:26:57 +08:00
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}`;
2026-03-15 00:10:01 +08:00
setAttachedFile(null);
}
2026-03-15 18:41:58 +08:00
const controller = new AbortController();
2026-03-17 20:12:48 +08:00
abortControllersRef.current[targetSessionKey] = controller;
generatingSessionsRef.current[targetSessionKey] = true;
setIsLoadingForSession(targetSessionKey, true);
2026-03-14 15:52:27 +08:00
try {
2026-03-15 21:57:38 +08:00
const assistantId = (Date.now() + 1).toString();
2026-03-17 20:12:48 +08:00
setMessagesForSession(targetSessionKey, prev => [...prev, {
2026-03-15 21:57:38 +08:00
id: assistantId,
role: "assistant",
content: "",
2026-03-17 21:32:01 +08:00
awaitingFirstToken: true,
2026-03-21 21:26:57 +08:00
progressLogs: [t('requestSubmittedRouting')],
2026-03-15 21:57:38 +08:00
}]);
2026-03-20 16:54:21 +08:00
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) {
2026-03-28 14:46:50 +08:00
return msg;
2026-03-20 16:54:21 +08:00
} else {
2026-03-20 17:03:05 +08:00
// 对于普通的阶段性日志,取消 8 条限制,允许滚动查看所有历史
2026-03-17 21:32:01 +08:00
const current = msg.progressLogs || [];
if (current[current.length - 1] === text) return msg;
2026-03-20 17:03:05 +08:00
const next = [...current, text];
2026-03-17 21:32:01 +08:00
return { ...msg, progressLogs: next };
2026-03-20 16:54:21 +08:00
}
})
);
};
2026-03-17 21:32:01 +08:00
2026-03-15 21:57:38 +08:00
const token = localStorage.getItem("token");
const effectiveModelId = selectedModelId || currentModel?.id || "";
2026-03-16 16:12:35 +08:00
2026-03-17 11:38:02 +08:00
let source = selectedDataSource || "postgres";
2026-03-16 16:12:35 +08:00
2026-03-16 22:18:23 +08:00
const useUploadSource = Boolean(currentAttachedFile?.url?.startsWith("local://"));
2026-03-16 16:12:35 +08:00
if (useUploadSource) {
source = "upload";
}
2026-03-15 21:57:38 +08:00
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({
2026-03-15 00:10:01 +08:00
message: messagePayload,
2026-03-17 20:12:48 +08:00
session_id: targetSessionKey,
2026-03-28 08:58:02 +08:00
project_id: currentProject?.id,
2026-03-14 23:15:41 +08:00
model_id: effectiveModelId,
2026-03-15 22:16:04 +08:00
skill_ids: selectedSkillIds,
2026-03-15 21:57:38 +08:00
source,
prefer_sql_chart: preferSqlChart,
file_url: fileUrl,
2026-03-17 11:38:02 +08:00
route_mode: "auto",
2026-03-15 21:57:38 +08:00
}),
signal: controller.signal,
});
if (!response.ok || !response.body) {
const err = await response.json().catch(() => ({}));
2026-03-21 21:26:57 +08:00
throw new Error(err.detail || t('streamResponseFailed'));
2026-03-15 21:57:38 +08:00
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let streamedText = "";
let streamedViz: MessageViz | null = null;
2026-03-17 20:40:56 +08:00
let hasFinalPayload = false;
let hasDonePayload = false;
let rafPending = false;
let renderedText = "";
2026-03-28 14:46:50 +08:00
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
)
);
});
};
2026-03-17 20:40:56 +08:00
const flushAssistant = (force = false) => {
2026-03-19 12:27:31 +08:00
if (streamedText === renderedText && !force) return;
2026-03-17 20:40:56 +08:00
if (force) {
renderedText = streamedText;
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
2026-03-19 12:27:31 +08:00
msg.id === assistantId ? { ...msg, content: streamedText, awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
2026-03-17 20:40:56 +08:00
)
);
return;
}
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (streamedText === renderedText) return;
renderedText = streamedText;
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
2026-03-19 12:27:31 +08:00
msg.id === assistantId ? { ...msg, content: streamedText, awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
2026-03-17 20:40:56 +08:00
)
);
});
};
2026-03-15 21:57:38 +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;
const payload = JSON.parse(payloadText) as {
type: string;
content?: string;
2026-03-20 16:54:21 +08:00
is_reasoning?: boolean;
2026-03-28 14:46:50 +08:00
tool_hint?: boolean;
2026-03-15 21:57:38 +08:00
sql?: string;
result?: unknown;
error?: string;
2026-03-17 21:32:01 +08:00
selected?: string;
reason?: string;
2026-03-15 21:57:38 +08:00
chart?: { chart_spec?: ChartSpec | null; reasoning?: string; can_visualize?: boolean; chart_type?: string } | null;
2026-03-27 15:10:33 +08:00
artifacts?: unknown;
2026-03-28 14:46:50 +08:00
reasoning_content?: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
2026-03-15 21:57:38 +08:00
};
if (payload.type === "delta" && payload.content) {
streamedText = `${streamedText}${payload.content}`;
2026-03-17 20:40:56 +08:00
flushAssistant(false);
2026-03-15 21:57:38 +08:00
}
2026-03-17 21:32:01 +08:00
if (payload.type === "routing") {
2026-03-21 21:26:57 +08:00
const selected = payload.selected === "sql" ? t('sqlAnalysis') : t('generalConversation');
2026-03-17 21:32:01 +08:00
const reason = payload.reason ? `${payload.reason}` : "";
2026-03-21 21:26:57 +08:00
pushProgressLog(t('routingInfo', { selected, reason }));
2026-03-17 21:32:01 +08:00
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, routeInfo: `${selected}${reason}` } : msg
)
);
}
if (payload.type === "progress" && payload.content) {
2026-03-28 14:46:50 +08:00
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);
}
2026-03-17 21:32:01 +08:00
}
2026-03-27 15:10:33 +08:00
if (payload.type === "final") {
2026-03-17 20:40:56 +08:00
hasFinalPayload = true;
2026-03-27 15:10:33 +08:00
if (typeof payload.content === "string") {
streamedText = payload.content;
}
2026-03-28 14:46:50 +08:00
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);
}
2026-03-17 20:40:56 +08:00
flushAssistant(true);
2026-03-21 21:26:57 +08:00
pushProgressLog(t('answerGenerationCompleted'));
2026-03-27 15:10:33 +08:00
const messageArtifacts = normalizeArtifacts(payload.artifacts);
2026-03-17 20:12:48 +08:00
setMessagesForSession(targetSessionKey, (prev) =>
2026-03-15 21:57:38 +08:00
prev.map((msg) =>
2026-03-28 14:46:50 +08:00
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 } : msg
2026-03-15 21:57:38 +08:00
)
);
}
2026-03-17 20:40:56 +08:00
if (payload.type === "done") {
hasDonePayload = true;
}
2026-03-15 21:57:38 +08:00
if (payload.type === "error") {
2026-03-21 21:26:57 +08:00
throw new Error(payload.content || t('streamResponseError'));
2026-03-15 21:57:38 +08:00
}
2026-03-14 15:52:27 +08:00
2026-03-15 21:57:38 +08:00
if (payload.type === "viz") {
2026-03-19 15:33:12 +08:00
if (payload.chart?.chart_spec) {
2026-03-21 21:26:57 +08:00
pushProgressLog(t('chartGenerationCompleted'));
2026-03-19 15:33:12 +08:00
} else if (payload.sql) {
2026-03-21 21:26:57 +08:00
pushProgressLog(t('dataQueryCompleted'));
2026-03-19 15:33:12 +08:00
}
2026-03-15 21:57:38 +08:00
streamedViz = buildMessageViz(payload);
2026-03-19 12:27:31 +08:00
flushAssistant(true); // 立即把 viz 状态刷入 messages
2026-03-15 21:57:38 +08:00
}
2026-03-14 15:52:27 +08:00
}
2026-03-15 21:57:38 +08:00
}
2026-03-28 14:46:50 +08:00
flushReasoning(true);
2026-03-17 20:40:56 +08:00
flushAssistant(true);
if (!streamedText && (hasFinalPayload || hasDonePayload)) {
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
2026-03-21 21:26:57 +08:00
msg.id === assistantId ? { ...msg, content: t('noReply'), awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
2026-03-17 20:40:56 +08:00
)
);
2026-03-15 21:57:38 +08:00
}
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")) {
2026-03-17 20:12:48 +08:00
setMessagesForSession(targetSessionKey, (prev) =>
2026-03-15 18:41:58 +08:00
prev.map((msg) =>
msg.awaitingFirstToken
2026-03-21 21:26:57 +08:00
? { ...msg, awaitingFirstToken: false, content: msg.content || t('outputInterrupted') }
2026-03-15 18:41:58 +08:00
: msg
)
);
return;
}
2026-03-17 20:12:48 +08:00
setMessagesForSession(targetSessionKey, prev => [...prev, {
2026-03-14 15:52:27 +08:00
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `Sorry, something went wrong: ${error.message}`
}]);
} finally {
2026-03-17 20:12:48 +08:00
if (abortControllersRef.current[targetSessionKey] === controller) {
delete abortControllersRef.current[targetSessionKey];
2026-03-15 18:41:58 +08:00
}
2026-03-17 20:12:48 +08:00
generatingSessionsRef.current[targetSessionKey] = false;
setIsLoadingForSession(targetSessionKey, 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-28 16:25:35 +08:00
<div className="flex flex-col h-full bg-background relative">
2026-03-15 22:16:04 +08:00
{/* Header with Model Selection */}
2026-03-28 16:25:35 +08:00
<div className="px-4 py-3 flex items-center justify-between border-b border-border bg-background/50 backdrop-blur-md sticky top-0 z-20">
2026-03-14 20:58:38 +08:00
<Popover open={modelOpen} onOpenChange={setModelOpen}>
2026-03-28 16:25:35 +08:00
<PopoverTrigger className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors group">
<span className="font-semibold text-foreground">
2026-03-15 22:16:04 +08:00
{selectedModelId ? models.find(m => m.id === selectedModelId)?.name || 'DataClaw' : 'DataClaw'}
</span>
2026-03-28 16:25:35 +08:00
<ChevronDown className="h-4 w-4 text-muted-foreground group-hover:text-muted-foreground transition-colors" />
2026-03-14 20:58:38 +08:00
</PopoverTrigger>
2026-03-15 22:16:04 +08:00
<PopoverContent className="w-[280px] p-0" align="start">
2026-03-14 20:58:38 +08:00
<Command>
2026-03-21 21:26:57 +08:00
<CommandInput placeholder={t('searchModel')} />
2026-03-15 22:16:04 +08:00
<CommandList className="max-h-[300px]">
2026-03-21 21:26:57 +08:00
<CommandEmpty>{t('modelNotFound')}</CommandEmpty>
<CommandGroup heading={t('availableModels')}>
2026-03-14 20:58:38 +08:00
{models.map((model) => (
<CommandItem
key={model.id}
onSelect={() => {
setSelectedModelId(model.id);
setModelOpen(false);
}}
2026-03-15 22:16:04 +08:00
className="flex items-center gap-2 py-2.5 cursor-pointer"
2026-03-14 20:58:38 +08:00
>
<div className="flex flex-col">
2026-03-28 16:25:35 +08:00
<span className="font-medium text-foreground">{model.name || model.model}</span>
<span className="text-xs text-muted-foreground">{model.provider}</span>
2026-03-14 20:58:38 +08:00
</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-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 */}
2026-03-15 22:16:04 +08:00
<div className="w-full max-w-4xl px-4">
<div className="relative group">
2026-03-28 16:25:35 +08:00
<div className="flex flex-col bg-background rounded-[26px] border border-border shadow-[0_2px_12px_rgba(0,0,0,0.04)] transition-all duration-200">
2026-03-15 22:16:04 +08:00
{renderFileCard()}
2026-03-16 22:18:23 +08:00
{renderActiveSelections()}
2026-03-15 22:16:04 +08:00
<div className="flex items-center pl-2 pr-2 py-2">
<div className="flex items-center">
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
2026-03-28 16:25:35 +08:00
<PopoverTrigger className="flex items-center justify-center h-9 w-9 rounded-full hover:bg-muted transition-colors text-muted-foreground">
2026-03-15 22:16:04 +08:00
<Plus className="h-5 w-5" />
</PopoverTrigger>
2026-03-28 16:25:35 +08:00
<PopoverContent side="bottom" align="start" className="w-[480px] p-0 mt-2 overflow-hidden rounded-2xl border-border shadow-xl">
2026-03-15 22:16:04 +08:00
<div className="flex divide-x divide-zinc-100">
{/* Left Column: Data Source */}
2026-03-28 16:25:35 +08:00
<div className="flex-1 p-3 bg-muted/50/50">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
2026-03-15 22:16:04 +08:00
<Database className="h-3 w-3" />
2026-03-21 21:26:57 +08:00
{t('dataSource')}
2026-03-15 22:16:04 +08:00
</div>
<div className="space-y-0.5">
2026-03-16 16:12:35 +08:00
{availableDataSources.map((ds) => (
2026-03-15 22:16:04 +08:00
<button
key={ds.id}
onClick={() => {
2026-03-16 23:16:33 +08:00
void handleSelectDataSource(ds.id);
2026-03-15 22:16:04 +08:00
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
selectedDataSource === ds.id
2026-03-28 16:25:35 +08:00
? "bg-background text-foreground shadow-sm ring-1 ring-border"
: "text-muted-foreground hover:bg-background hover:shadow-sm"
2026-03-15 22:16:04 +08:00
)}
>
<div className="flex items-center gap-2.5">
2026-03-28 16:25:35 +08:00
<Database className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-muted-foreground")} />
2026-03-16 16:12:35 +08:00
<span className="font-medium">{ds.name}</span>
2026-03-15 22:16:04 +08:00
</div>
{selectedDataSource === ds.id && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
</button>
))}
2026-03-16 22:18:23 +08:00
{selectedDataSource && (
2026-03-28 16:25:35 +08:00
<div className="mt-2 pt-2 border-t border-border">
2026-03-16 22:18:23 +08:00
<button
2026-03-16 23:16:33 +08:00
onClick={() => {
void handleClearDataSource();
}}
2026-03-28 16:25:35 +08:00
className="w-full py-1.5 text-[11px] text-muted-foreground hover:text-muted-foreground transition-colors flex items-center justify-center gap-1"
2026-03-16 22:18:23 +08:00
>
2026-03-21 21:26:57 +08:00
{t('clearSelected')}
2026-03-16 22:18:23 +08:00
</button>
2026-03-16 16:12:35 +08:00
</div>
2026-03-16 22:18:23 +08:00
)}
2026-03-15 22:16:04 +08:00
</div>
</div>
{/* Right Column: Skills */}
2026-03-28 16:25:35 +08:00
<div className="flex-1 p-3 bg-background">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
2026-03-15 22:16:04 +08:00
<Wand2 className="h-3 w-3" />
Skills
</div>
<div className="space-y-0.5 max-h-[300px] overflow-y-auto pr-1">
{availableSkills.length > 0 ? (
availableSkills.map((skill) => {
const isSelected = selectedSkillIds.includes(skill.id);
return (
<button
key={skill.id}
onClick={() => {
2026-03-16 22:18:23 +08:00
setSelectedSkillIds((prev) =>
isSelected
? prev.filter((id) => id !== skill.id)
2026-03-15 22:16:04 +08:00
: [...prev, skill.id]
);
}}
className={cn(
2026-03-16 22:18:23 +08:00
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
2026-03-15 22:16:04 +08:00
isSelected
2026-03-28 16:25:35 +08:00
? "bg-background text-foreground shadow-sm ring-1 ring-border"
: "text-muted-foreground hover:bg-background hover:shadow-sm"
2026-03-15 22:16:04 +08:00
)}
>
2026-03-16 22:18:23 +08:00
<div className="flex items-center text-left">
2026-03-15 22:16:04 +08:00
<span className="font-medium">{skill.name}</span>
</div>
{isSelected && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
</button>
);
})
) : (
<div className="px-3 py-8 text-center">
<Zap className="h-8 w-8 text-zinc-100 mx-auto mb-2" />
2026-03-28 16:25:35 +08:00
<p className="text-xs text-muted-foreground">{t('noAvailableSkills')}</p>
2026-03-15 22:16:04 +08:00
</div>
)}
</div>
{selectedSkillIds.length > 0 && (
2026-03-28 16:25:35 +08:00
<div className="mt-2 pt-2 border-t border-border">
2026-03-15 22:16:04 +08:00
<button
onClick={() => setSelectedSkillIds([])}
2026-03-28 16:25:35 +08:00
className="w-full py-1.5 text-[11px] text-muted-foreground hover:text-muted-foreground transition-colors flex items-center justify-center gap-1"
2026-03-15 22:16:04 +08:00
>
2026-03-21 21:26:57 +08:00
{t('clearSelectedWithCount', { count: selectedSkillIds.length })}
2026-03-15 22:16:04 +08:00
</button>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
2026-03-28 20:00:48 +08:00
{isRecording ? (
<>
<div className="flex-1 px-3">
<div className="relative h-10 flex items-center">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 border-t border-dashed border-muted-foreground/40" />
<div className="ml-auto flex items-center gap-[3px] pr-2">
{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 (
<span
key={`recording-wave-empty-${idx}`}
className="w-[3px] rounded-full bg-foreground/90 transition-all duration-75"
style={{ height: `${height}px` }}
/>
);
})}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={cancelRecording}
className="flex items-center justify-center h-10 w-10 rounded-full text-foreground hover:bg-muted transition-colors"
title={t('cancel', '取消')}
>
<X className="h-5 w-5" />
</button>
<button
onClick={confirmRecording}
className="flex items-center justify-center h-10 w-10 rounded-full text-foreground hover:bg-muted transition-colors"
title={t('confirm', '确认')}
>
<Check className="h-5 w-5" />
</button>
</div>
</>
) : (
<>
<input
type="text"
value={input}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={isTranscribing ? t('transcribing', '正在识别...') : t('askAnything')}
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-foreground placeholder:text-muted-foreground/50 outline-none"
disabled={isLoading || isTranscribing}
/>
<SlashCommandMenu
isOpen={slashQuery !== null}
skills={filteredSlashSkills}
selectedIndex={slashIndex}
onSelect={handleSelectSlashSkill}
onClose={() => setSlashQuery(null)}
/>
<div className="flex items-center gap-1">
<button
onClick={startRecording}
disabled={isLoading || isTranscribing}
className="flex items-center justify-center h-10 w-10 rounded-full transition-all duration-200 bg-transparent text-muted-foreground hover:bg-muted"
title={t('voiceInput', '语音输入')}
>
{isTranscribing ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : (
<Mic className="h-5 w-5" />
)}
</button>
<button
onClick={() => setIsVoiceSettingsOpen(true)}
className="flex items-center justify-center h-10 w-10 rounded-full bg-transparent text-muted-foreground hover:bg-muted transition-colors"
title={t('voiceSettings', '语音输入配置')}
>
<Settings className="h-4 w-4" />
</button>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className={cn(
"flex items-center justify-center h-10 w-10 rounded-full transition-all duration-200",
(input.trim() || attachedFile || activeDataFile) && !isLoading
? "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm"
: "bg-muted text-muted-foreground/50"
)}
>
{isLoading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ArrowUp className="h-6 w-6" />
)}
</button>
</div>
</>
)}
2026-03-14 15:52:27 +08:00
</div>
</div>
2026-03-15 22:16:04 +08:00
<div className="mt-4 flex flex-wrap justify-center gap-2">
{/* Common Questions or suggestions could go here */}
</div>
2026-03-14 15:52:27 +08:00
</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-22 00:42:48 +08:00
{messages.map((msg, msgIdx) => {
const isMessageGenerating = isLoading && msgIdx === messages.length - 1;
2026-03-18 22:42:18 +08:00
const { markdown, reportHtml } = splitReportHtml(msg.content);
2026-03-19 17:48:52 +08:00
const externalReportUrl = extractExternalReport(msg.content);
2026-03-28 14:46:50 +08:00
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");
2026-03-18 22:42:18 +08:00
return (
2026-03-14 15:52:27 +08:00
<div
key={msg.id}
className={`flex gap-4 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{msg.role !== "user" && (
2026-03-17 16:58:21 +08:00
<div className="w-8 h-8 flex items-center justify-center shrink-0 mt-1">
<span className="text-2xl">🦞</span>
2026-03-14 15:52:27 +08:00
</div>
)}
<div
className={`rounded-2xl px-5 py-3.5 text-[15px] leading-relaxed max-w-[85%] shadow-sm ${
msg.role === "user"
2026-03-28 16:25:35 +08:00
? "bg-muted text-foreground/90"
: "bg-background border border-border text-foreground/80 overflow-hidden"
2026-03-14 15:52:27 +08:00
}`}
>
2026-03-14 22:15:38 +08:00
{msg.role === "assistant" ? (
2026-03-17 21:32:01 +08:00
<>
2026-03-28 14:46:50 +08:00
{displayedThinkingContent && (
2026-03-28 16:25:35 +08:00
<div className="mb-3 rounded-xl border border-border bg-muted/50/50 p-3 text-sm text-muted-foreground font-mono whitespace-pre-wrap leading-relaxed shadow-inner">
2026-03-28 14:46:50 +08:00
<button
type="button"
onClick={() => toggleThinkingCollapsed(msg.id)}
2026-03-28 16:25:35 +08:00
className="w-full flex items-center justify-between gap-2 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:text-foreground/80 transition-colors"
2026-03-28 14:46:50 +08:00
>
<span className="flex items-center gap-2">
<Settings className={`h-3.5 w-3.5 ${msg.awaitingFirstToken ? 'animate-spin' : ''}`} />
{t('thinkingProcess')}
</span>
<span className="flex items-center gap-2 normal-case text-[11px]">
{msg.usage?.total_tokens ? (
<span>{t('thinkingTokens', { count: msg.usage.total_tokens })}</span>
) : msg.reasoningContent ? (
<span>{t('thinkingCharCount', { count: msg.reasoningContent.length })}</span>
) : null}
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
void copyThinkingContent(msg.id, displayedThinkingContent);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
void copyThinkingContent(msg.id, displayedThinkingContent);
}
}}
2026-03-28 16:25:35 +08:00
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 hover:bg-muted/80/70 transition-colors"
2026-03-28 14:46:50 +08:00
>
{thinkingCopiedByMessage[msg.id] ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
<span>{thinkingCopiedByMessage[msg.id] ? t('copied') : t('copy')}</span>
</span>
<span className="inline-flex items-center gap-1">
{isThinkingCollapsed(msg.id) ? t('expandThinking') : t('collapseThinking')}
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", isThinkingCollapsed(msg.id) ? "-rotate-90" : "rotate-0")} />
</span>
</span>
</button>
{!isThinkingCollapsed(msg.id) && (
<div className="max-h-[280px] overflow-y-auto pr-1">
{displayedThinkingContent}
</div>
)}
2026-03-20 16:54:21 +08:00
</div>
)}
2026-03-17 21:32:01 +08:00
{msg.progressLogs && msg.progressLogs.length > 0 ? (
2026-03-28 16:25:35 +08:00
<div className="mb-2 rounded-xl border border-border bg-muted/50/70 px-3 py-2">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1.5 pb-1.5 border-b border-border/50">
2026-03-22 00:42:48 +08:00
{isMessageGenerating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />}
<span>{isMessageGenerating ? t('processing') : t('processCompleted')}</span>
2026-03-17 21:32:01 +08:00
</div>
2026-03-22 00:42:48 +08:00
<div
className="space-y-1.5 max-h-[160px] overflow-y-auto pr-1"
ref={(el) => {
if (el && isMessageGenerating) {
el.scrollTop = el.scrollHeight;
}
}}
>
2026-03-18 10:57:48 +08:00
{msg.progressLogs.map((log, idx, arr) => {
const isLast = idx === arr.length - 1;
2026-03-22 00:42:48 +08:00
// 只有当是整个会话的最后一条消息,且当前日志是最后一条时,才显示 loading 动画
const isLoadingLog = isLast && isMessageGenerating;
2026-03-18 10:57:48 +08:00
return (
2026-03-28 16:25:35 +08:00
<div key={`${msg.id}-log-${idx}`} className="flex items-start gap-2 text-[12px] text-muted-foreground leading-5">
2026-03-22 00:42:48 +08:00
{isLoadingLog ? (
2026-03-20 17:03:05 +08:00
<Settings className="mt-0.5 h-3.5 w-3.5 text-amber-500 animate-spin shrink-0" />
2026-03-18 10:57:48 +08:00
) : (
2026-03-20 17:03:05 +08:00
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 text-emerald-500 shrink-0" />
2026-03-18 10:57:48 +08:00
)}
2026-03-20 17:03:05 +08:00
<span className="break-words">{log}</span>
2026-03-18 10:57:48 +08:00
</div>
);
})}
2026-03-17 21:32:01 +08:00
</div>
</div>
) : null}
{msg.awaitingFirstToken && !msg.content ? (
2026-03-28 16:25:35 +08:00
<div className="flex items-center gap-2 text-muted-foreground text-sm py-1">
2026-03-17 21:32:01 +08:00
<Loader2 className="h-4 w-4 animate-spin" />
2026-03-21 21:26:57 +08:00
<span>{t('modelThinking')}</span>
2026-03-15 11:07:18 +08:00
</div>
2026-03-17 21:32:01 +08:00
) : (
<>
2026-03-18 22:42:18 +08:00
{markdown ? (
2026-03-28 16:25:35 +08:00
<div className="prose prose-sm prose-zinc dark:prose-invert 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-muted/50 prose-pre:text-foreground/90 prose-pre:border prose-pre:border-border">
2026-03-18 22:42:18 +08:00
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{markdown}
</ReactMarkdown>
</div>
) : null}
{reportHtml ? (
2026-03-28 16:25:35 +08:00
<div className="mt-3 rounded-xl border border-border overflow-hidden bg-background">
2026-03-18 22:42:18 +08:00
<iframe
title={`report-${msg.id}`}
srcDoc={reportHtml}
2026-03-27 15:59:23 +08:00
sandbox="allow-same-origin allow-scripts"
2026-03-28 16:25:35 +08:00
className="w-full h-[620px] bg-background"
2026-03-27 15:59:23 +08:00
onLoad={(e) => {
try {
const doc = (e.target as HTMLIFrameElement).contentDocument;
if (doc) {
const style = doc.createElement('style');
style.textContent = `html, body { overflow: auto !important; }`;
doc.head.appendChild(style);
}
} catch (err) {
console.error("Failed to inject styles", err);
}
}}
2026-03-18 22:42:18 +08:00
/>
</div>
) : null}
2026-03-19 17:48:52 +08:00
{externalReportUrl ? (
<div className="mt-4 flex">
<a
href={externalReportUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 rounded-lg text-sm font-medium transition-colors"
>
<ExternalLink className="h-4 w-4" />
2026-03-21 21:26:57 +08:00
{t('openReportInNewTab')}
2026-03-19 17:48:52 +08:00
</a>
</div>
) : null}
2026-03-27 15:10:33 +08:00
{msg.artifacts && msg.artifacts.length > 0 ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{msg.artifacts.map((artifact, artifactIndex) => (
2026-03-28 16:25:35 +08:00
<div key={`${msg.id}-artifact-${artifactIndex}`} className="rounded-xl border border-border bg-muted/50/60 px-3 py-2.5">
2026-03-27 15:10:33 +08:00
<div className="flex items-center gap-2.5">
2026-03-28 16:25:35 +08:00
<div className="h-8 w-8 rounded-lg bg-background border border-border flex items-center justify-center text-muted-foreground shrink-0">
2026-03-27 15:10:33 +08:00
<FileText className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
2026-03-28 16:25:35 +08:00
<div className="text-sm font-medium text-foreground/90 truncate">{artifact.name}</div>
<div className="text-[11px] text-muted-foreground">{formatArtifactSize(artifact.size)}</div>
2026-03-27 15:10:33 +08:00
</div>
</div>
<div className="mt-2 flex items-center gap-2">
{artifact.previewable && artifact.preview_url ? (
<button
onClick={() => setArtifactPreview({ name: artifact.name, mimeType: artifact.mime_type, previewUrl: artifact.preview_url || "" })}
2026-03-28 16:25:35 +08:00
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-foreground/80 hover:bg-background transition-colors"
2026-03-27 15:10:33 +08:00
>
<Eye className="h-3.5 w-3.5" />
{t('preview')}
</button>
) : null}
<a
href={artifact.download_url}
target="_blank"
rel="noopener noreferrer"
2026-03-28 16:25:35 +08:00
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-foreground/80 hover:bg-background transition-colors"
2026-03-27 15:10:33 +08:00
>
<Download className="h-3.5 w-3.5" />
{t('download')}
</a>
</div>
</div>
))}
</div>
) : null}
2026-03-17 21:32:01 +08:00
{msg.viz ? (
2026-03-28 16:25:35 +08:00
<div className="mt-3 pt-3 border-t border-border">
2026-03-17 21:32:01 +08:00
<InlineVisualizationCard viz={msg.viz} />
</div>
) : null}
</>
)}
</>
2026-03-14 22:15:38 +08:00
) : (
msg.content
)}
2026-03-14 15:52:27 +08:00
</div>
{msg.role === "user" && (
2026-03-28 16:25:35 +08:00
<div className="w-8 h-8 rounded-full bg-muted/80 flex items-center justify-center text-muted-foreground shrink-0 mt-1">
2026-03-14 15:52:27 +08:00
<User className="h-4 w-4" />
</div>
)}
</div>
2026-03-18 22:42:18 +08:00
)})}
2026-03-14 15:52:27 +08:00
<div ref={scrollRef} />
</div>
)}
</div>
</ScrollArea>
{/* Floating Input for Chat State */}
2026-03-15 22:16:04 +08:00
{messages.length > 0 && (
2026-03-28 16:25:35 +08:00
<div className="px-4 pb-6 pt-3 border-t border-border bg-background">
2026-03-15 22:16:04 +08:00
<div className="relative group max-w-4xl mx-auto">
2026-03-28 16:25:35 +08:00
<div className="flex flex-col bg-background rounded-[26px] border border-border shadow-[0_2px_12px_rgba(0,0,0,0.04)] transition-all duration-200">
2026-03-15 22:16:04 +08:00
{renderFileCard()}
2026-03-16 22:18:23 +08:00
{renderActiveSelections()}
2026-03-15 22:16:04 +08:00
<div className="flex items-center pl-2 pr-2 py-2">
<div className="flex items-center">
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
2026-03-28 16:25:35 +08:00
<PopoverTrigger className="flex items-center justify-center h-9 w-9 rounded-full hover:bg-muted transition-colors text-muted-foreground">
2026-03-15 22:16:04 +08:00
<Plus className="h-5 w-5" />
</PopoverTrigger>
2026-03-28 16:25:35 +08:00
<PopoverContent side="top" align="start" className="w-[480px] p-0 mb-2 overflow-hidden rounded-2xl border-border shadow-xl">
2026-03-15 22:16:04 +08:00
<div className="flex divide-x divide-zinc-100">
{/* Left Column: Data Source */}
2026-03-28 16:25:35 +08:00
<div className="flex-1 p-3 bg-muted/50/50">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
2026-03-15 22:16:04 +08:00
<Database className="h-3 w-3" />
2026-03-21 21:26:57 +08:00
{t('dataSource')}
2026-03-15 22:16:04 +08:00
</div>
<div className="space-y-0.5">
2026-03-16 16:12:35 +08:00
{availableDataSources.map((ds) => (
2026-03-15 22:16:04 +08:00
<button
key={ds.id}
onClick={() => {
2026-03-16 23:16:33 +08:00
void handleSelectDataSource(ds.id);
2026-03-15 22:16:04 +08:00
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
selectedDataSource === ds.id
2026-03-28 16:25:35 +08:00
? "bg-background text-foreground shadow-sm ring-1 ring-border"
: "text-muted-foreground hover:bg-background hover:shadow-sm"
2026-03-15 22:16:04 +08:00
)}
>
<div className="flex items-center gap-2.5">
2026-03-28 16:25:35 +08:00
<Database className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-muted-foreground")} />
2026-03-16 16:12:35 +08:00
<span className="font-medium">{ds.name}</span>
2026-03-15 22:16:04 +08:00
</div>
{selectedDataSource === ds.id && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
</button>
))}
2026-03-16 22:18:23 +08:00
{selectedDataSource && (
2026-03-28 16:25:35 +08:00
<div className="mt-2 pt-2 border-t border-border">
2026-03-16 22:18:23 +08:00
<button
2026-03-16 23:16:33 +08:00
onClick={() => {
void handleClearDataSource();
}}
2026-03-28 16:25:35 +08:00
className="w-full py-1.5 text-[11px] text-muted-foreground hover:text-muted-foreground transition-colors flex items-center justify-center gap-1"
2026-03-16 22:18:23 +08:00
>
2026-03-21 21:26:57 +08:00
{t('clearSelected')}
2026-03-16 22:18:23 +08:00
</button>
2026-03-16 16:12:35 +08:00
</div>
2026-03-16 22:18:23 +08:00
)}
2026-03-15 22:16:04 +08:00
</div>
</div>
{/* Right Column: Skills */}
2026-03-28 16:25:35 +08:00
<div className="flex-1 p-3 bg-background">
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
2026-03-15 22:16:04 +08:00
<Wand2 className="h-3 w-3" />
Skills
</div>
<div className="space-y-0.5 max-h-[300px] overflow-y-auto pr-1">
{availableSkills.length > 0 ? (
availableSkills.map((skill) => {
const isSelected = selectedSkillIds.includes(skill.id);
return (
<button
key={skill.id}
onClick={() => {
2026-03-16 22:18:23 +08:00
setSelectedSkillIds((prev) =>
isSelected
? prev.filter((id) => id !== skill.id)
2026-03-15 22:16:04 +08:00
: [...prev, skill.id]
);
}}
className={cn(
2026-03-16 22:18:23 +08:00
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
2026-03-15 22:16:04 +08:00
isSelected
2026-03-28 16:25:35 +08:00
? "bg-background text-foreground shadow-sm ring-1 ring-border"
: "text-muted-foreground hover:bg-background hover:shadow-sm"
2026-03-15 22:16:04 +08:00
)}
>
2026-03-16 22:18:23 +08:00
<div className="flex items-center text-left">
2026-03-15 22:16:04 +08:00
<span className="font-medium">{skill.name}</span>
</div>
{isSelected && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
</button>
);
})
) : (
<div className="px-3 py-8 text-center">
<Zap className="h-8 w-8 text-zinc-100 mx-auto mb-2" />
2026-03-28 16:25:35 +08:00
<p className="text-xs text-muted-foreground">{t('noAvailableSkills')}</p>
2026-03-15 22:16:04 +08:00
</div>
)}
</div>
{selectedSkillIds.length > 0 && (
2026-03-28 16:25:35 +08:00
<div className="mt-2 pt-2 border-t border-border">
2026-03-15 22:16:04 +08:00
<button
onClick={() => setSelectedSkillIds([])}
2026-03-28 16:25:35 +08:00
className="w-full py-1.5 text-[11px] text-muted-foreground hover:text-muted-foreground transition-colors flex items-center justify-center gap-1"
2026-03-15 22:16:04 +08:00
>
2026-03-21 21:26:57 +08:00
{t('clearSelectedWithCount', { count: selectedSkillIds.length })}
2026-03-15 22:16:04 +08:00
</button>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
2026-03-28 20:00:48 +08:00
{isRecording ? (
<>
<div className="flex-1 px-3">
<div className="relative h-10 flex items-center">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 border-t border-dashed border-muted-foreground/40" />
<div className="ml-auto flex items-center gap-[3px] pr-2">
{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 (
<span
key={`recording-wave-chat-${idx}`}
className="w-[3px] rounded-full bg-foreground/90 transition-all duration-75"
style={{ height: `${height}px` }}
/>
);
})}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={cancelRecording}
className="flex items-center justify-center h-10 w-10 rounded-full text-foreground hover:bg-muted transition-colors"
title={t('cancel', '取消')}
>
<X className="h-5 w-5" />
</button>
<button
onClick={confirmRecording}
className="flex items-center justify-center h-10 w-10 rounded-full text-foreground hover:bg-muted transition-colors"
title={t('confirm', '确认')}
>
<Check className="h-5 w-5" />
</button>
</div>
</>
) : (
<>
<input
type="text"
value={input}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={isTranscribing ? t('transcribing', '正在识别...') : t('askAnything')}
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-foreground placeholder:text-muted-foreground/50 outline-none"
disabled={isLoading || isTranscribing}
/>
<SlashCommandMenu
isOpen={slashQuery !== null}
skills={filteredSlashSkills}
selectedIndex={slashIndex}
onSelect={handleSelectSlashSkill}
onClose={() => setSlashQuery(null)}
/>
<div className="flex items-center gap-1">
<button
onClick={startRecording}
disabled={isLoading || isTranscribing}
className="flex items-center justify-center h-10 w-10 rounded-full transition-all duration-200 bg-transparent text-muted-foreground hover:bg-muted"
title={t('voiceInput', '语音输入')}
>
{isTranscribing ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : (
<Mic className="h-5 w-5" />
)}
</button>
<button
onClick={() => setIsVoiceSettingsOpen(true)}
className="flex items-center justify-center h-10 w-10 rounded-full bg-transparent text-muted-foreground hover:bg-muted transition-colors"
title={t('voiceSettings', '语音输入配置')}
>
<Settings className="h-4 w-4" />
</button>
<button
onClick={isLoading ? handleForceStop : handleSend}
disabled={isLoading ? false : !input.trim()}
className={cn(
"flex items-center justify-center h-10 w-10 rounded-full transition-all duration-200",
(input.trim() || isLoading)
? (isLoading ? "bg-red-600 text-primary-foreground hover:bg-red-700" : "bg-primary text-primary-foreground hover:bg-primary/90")
: "bg-muted text-muted-foreground/50"
)}
>
{isLoading ? (
<Square className="h-4 w-4" />
) : (
<ArrowUp className="h-6 w-6" />
)}
</button>
</div>
</>
)}
2026-03-14 15:52:27 +08:00
</div>
2026-03-15 22:16:04 +08:00
</div>
<div className="mt-2 flex justify-center">
2026-03-28 16:25:35 +08:00
<p className="text-[11px] text-muted-foreground">
2026-03-21 21:26:57 +08:00
{t('dataClawDisclaimer')}
2026-03-15 22:16:04 +08:00
</p>
</div>
2026-03-14 15:52:27 +08:00
</div>
</div>
)}
2026-03-27 15:10:33 +08:00
<Dialog open={Boolean(artifactPreview)} onOpenChange={(open) => {
if (!open) setArtifactPreview(null);
}}>
<DialogContent className="sm:max-w-[min(1100px,95vw)] h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>{artifactPreview?.name || t('artifactPreview')}</DialogTitle>
</DialogHeader>
2026-03-28 16:25:35 +08:00
<div className="flex-1 min-h-0 rounded-lg border border-border bg-background overflow-hidden">
2026-03-27 15:10:33 +08:00
{artifactPreview?.mimeType.startsWith("image/") ? (
<img
src={artifactPreview.previewUrl}
alt={artifactPreview.name}
2026-03-28 16:25:35 +08:00
className="w-full h-full object-contain bg-muted/50"
2026-03-27 15:10:33 +08:00
/>
) : artifactPreview ? (
<iframe
title={artifactPreview.name}
src={artifactPreview.previewUrl}
2026-03-27 15:59:23 +08:00
className="w-full h-full border-0"
onLoad={(e) => {
try {
const doc = (e.target as HTMLIFrameElement).contentDocument;
if (doc) {
const style = doc.createElement('style');
style.textContent = `html, body { overflow: auto !important; }`;
doc.head.appendChild(style);
}
} catch (err) {
console.error("Failed to inject styles into iframe", err);
}
}}
2026-03-27 15:10:33 +08:00
/>
) : null}
</div>
</DialogContent>
</Dialog>
2026-03-28 20:00:48 +08:00
<Dialog open={isVoiceSettingsOpen} onOpenChange={setIsVoiceSettingsOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('voiceSettings', '语音输入配置')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="whisperUrl" className="text-right">
{t('serviceUrl', '服务地址')}
</Label>
<Input
id="whisperUrl"
value={whisperUrl}
onChange={(e) => setWhisperUrl(e.target.value)}
className="col-span-3"
placeholder="http://localhost:8001"
/>
</div>
<p className="text-xs text-muted-foreground px-1">
Whisper http://localhost:8001
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsVoiceSettingsOpen(false)}>{t('cancel', '取消')}</Button>
<Button onClick={() => handleSaveWhisperUrl(whisperUrl)}>{t('save', '保存')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2026-03-14 15:52:27 +08:00
</div>
);
}