import { useChat } from "@ai-sdk/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Banner, Button, Select, Spin, Tag } from "@douyinfe/semi-ui-19"; import { api, post } from "../api"; import type { Job } from "../types"; import { AssistantDialogue } from "./AssistantDialogue"; import { ResearchInput } from "./ResearchInput"; import { IconAIFilledLevel3, IconPlus, IconChevronRight, } from "@douyinfe/semi-icons"; import { contextLabel, contextReferences } from "./workspace"; import { chatTransport } from "./transport"; import { runLabels } from "./types"; import type { ChatMessage, Conversation, ConversationDetail, ModelSettings, PageContext, RunSnapshot, Resource, UIAction, } from "./types"; export function ChatPanel({ open, context, onClose, onAction, onChanged, onSettings, width, onWidth, timezone, jobs, action, }: { open: boolean; context: PageContext; onClose: () => void; onAction: (action: UIAction) => void; onChanged: (resources: Resource[]) => void; onSettings: () => void; width: number; onWidth: (width: number) => void; timezone?: string; jobs: Job[]; action: UIAction | null; }) { const [settings, setSettings] = useState(null); const [conversations, setConversations] = useState([]); const [conversationId, setConversationId] = useState(""); const [runs, setRuns] = useState([]); const [text, setText] = useState(""); const [failure, setFailure] = useState(""); const [loading, setLoading] = useState(false); const root = useRef(null); const input = useRef(null); const messagesViewport = useRef(null); const previousFocus = useRef(null); const seenChanges = useRef(new Set()); const current = useRef({ conversationId, context }); current.current = { conversationId, context }; const refreshRef = useRef<() => Promise>(async () => {}); const changedRef = useRef(onChanged); changedRef.current = onChanged; const observeChanges = useCallback((items: RunSnapshot[]) => { const changed = new Set(); for (const run of items) for (const call of run.tools ?? []) { if ( call.status === "completed" && call.presentation?.refresh.length && !seenChanges.current.has(call.id) ) { seenChanges.current.add(call.id); call.presentation.refresh.forEach((resource) => changed.add(resource), ); } } if (changed.size) changedRef.current([...changed]); }, []); const transport = useMemo(() => chatTransport(() => current.current), []); const chat = useChat({ id: conversationId || "unselected", transport, experimental_throttle: 50, onData: (part) => { if (part.type === "data-run") { const next = part.data; if (next.conversation_id !== current.current.conversationId) return; observeChanges([next]); setRuns((items) => { const old = items.find((run) => run.id === next.id); const merged = { ...old, ...next, tools: next.tools ?? old?.tools ?? [], }; return [...items.filter((run) => run.id !== next.id), merged]; }); } }, onFinish: async () => { await refreshRef.current(); }, onError: (error) => { setFailure(error.message); void refreshRef.current(); }, }); const refreshConversation = useCallback(async () => { const id = current.current.conversationId; if (!id) return; try { const detail = await api(`/ai/conversations/${id}`); if (current.current.conversationId !== id) return; chat.setMessages(detail.messages); setRuns(detail.runs); observeChanges(detail.runs); setConversations((items) => items.map((item) => item.id === id ? { id, title: detail.title } : item, ), ); } catch (e) { setFailure((e as Error).message); } }, [chat.setMessages, observeChanges]); refreshRef.current = refreshConversation; useEffect(() => { let active = true; const loadSettings = () => api("/ai/settings") .then((value) => { if (active) setSettings(value); }) .catch((e) => { if (active) setFailure(e.message); }); void loadSettings(); api("/ai/conversations") .then((items) => { if (active) { setConversations(items); setConversationId(items[0]?.id ?? ""); } }) .catch((e) => { if (active) setFailure(e.message); }); window.addEventListener("ai-settings-changed", loadSettings); return () => { active = false; window.removeEventListener("ai-settings-changed", loadSettings); }; }, []); useEffect(() => { setRuns([]); setFailure(""); setLoading(true); void refreshConversation().finally(() => setLoading(false)); }, [conversationId, refreshConversation]); useEffect(() => { if (action?.type !== "open_conversation") return; let live = true; void api( `/ai/conversations/${encodeURIComponent(action.conversation_id)}`, ) .then(async (detail) => { if (!live) return; await chat.stop(); if (!live) return; setConversations((items) => items.some((item) => item.id === detail.id) ? items : [{ id: detail.id, title: detail.title }, ...items], ); setConversationId(detail.id); setText(""); setFailure(""); }) .catch((e) => { if (live) setFailure(e.message); }); return () => { live = false; }; }, [action]); const streaming = chat.status === "streaming" || chat.status === "submitted"; const activeRun = runs.find((run) => ["running", "waiting_approval"].includes(run.status), ); useEffect(() => { if (!conversationId || streaming || activeRun?.status !== "running") return; const timer = window.setInterval(() => void refreshRef.current(), 3000); return () => clearInterval(timer); }, [conversationId, streaming, activeRun?.status]); useEffect(() => { if (!open) return; previousFocus.current = document.activeElement as HTMLElement; const editor = input.current?.querySelector( '[contenteditable="true"]', ); if (editor) editor.focus({ preventScroll: true }); else root.current ?.querySelector('button[aria-label="收起研究助手"]') ?.focus(); return () => { requestAnimationFrame(() => { if ( previousFocus.current?.isConnected && previousFocus.current !== document.body ) previousFocus.current.focus(); else document .querySelector('[aria-label="打开研究助手"]') ?.focus(); }); }; }, [open]); useEffect(() => { if (!open || !messagesViewport.current) return; // Scroll only the message region; empty conversations always start at the welcome. messagesViewport.current.scrollTop = chat.messages.length ? messagesViewport.current.scrollHeight : 0; }, [chat.messages, open]); async function createConversation() { setLoading(true); try { await chat.stop(); const value = await post("/ai/conversations"); setConversations((items) => [value, ...items]); setConversationId(value.id); setText(""); } catch (e) { setFailure((e as Error).message); } finally { setLoading(false); } } async function send() { if ( !ready || loading || !text.trim() || text.length > 20000 || !conversationId || activeRun || streaming ) return; const message = text; setText(""); setFailure(""); chat.clearError(); try { await chat.sendMessage({ text: message }); } catch (e) { setFailure((e as Error).message); } } async function decide(id: string, approved: boolean) { setFailure(""); chat.clearError(); try { await chat.sendMessage(undefined, { body: { decision: { id, approved } }, }); } catch (e) { setFailure((e as Error).message); } } async function stop() { if (!activeRun) return; try { await post(`/ai/runs/${activeRun.id}/cancel`); await chat.stop(); await refreshConversation(); // Completed tools are reconciled by refreshConversation; cancelling has no new business effect. } catch (e) { setFailure((e as Error).message); } } const cards = new Map( runs.flatMap((run) => run.tools ?? []).map((call) => [call.id, call]), ); const lastRun = runs.at(-1); const ready = !!settings?.enabled && settings.ready; return (