import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent } from "react"; import { Bot, CircleAlert, Globe2, KeyRound, LoaderCircle, PlugZap, SendHorizontal } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { chatWithAi, listAiBindings, type WebAiBindingSummary, type WebAiBindingsResponse, type WebAiChannel, type WebAiLocalTaskContextItem, WebAiApiError } from "@/services/ai-api"; import { deleteLocalAiChatSession, listLocalAiChatSessions, saveLocalAiChatSession, type LocalAiChatMessageRecord } from "@/services/local-ai-chat-repo"; import { listLocalTasksByUser } from "@/services/local-task-repo"; import type { WebSession } from "@/services/session-storage"; import { CHANNEL_META, CHANNEL_ORDER } from "@/components/ai/ai-shared"; type AiChatPageProps = { session: WebSession; }; type AiMessageRecord = LocalAiChatMessageRecord; function createEmptyMessages(): Record { return { USER_KEY: [], ASTRBOT: [], PUBLIC_POOL: [] }; } function createEmptySessionIds(): Partial> { return {}; } function formatTimeLabel(date = new Date()): string { return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); } function appendMessage( records: Record, channel: WebAiChannel, message: AiMessageRecord ): Record { return { ...records, [channel]: [...records[channel], message] }; } function buildLocalTaskContext( items: Awaited> ): WebAiLocalTaskContextItem[] { return items .filter((item) => item.status === "TODO" || item.status === "IN_PROGRESS") .slice(0, 20) .map((item) => ({ id: item.id, title: item.title, priority: item.priority, status: item.status, ddlAt: item.ddlAt, contentText: item.contentText, updatedAt: item.updatedAt })); } export function AiChatPage({ session }: AiChatPageProps) { const navigate = useNavigate(); const [bindingsResponse, setBindingsResponse] = useState(null); const [loadingBindings, setLoadingBindings] = useState(true); const [refreshingBindings, setRefreshingBindings] = useState(false); const [activeChannel, setActiveChannel] = useState("USER_KEY"); const [messagesByChannel, setMessagesByChannel] = useState< Record >(() => createEmptyMessages()); const [sessionIds, setSessionIds] = useState>>(() => createEmptySessionIds() ); const [draftMessage, setDraftMessage] = useState(""); const [sending, setSending] = useState(false); const [loadError, setLoadError] = useState(null); const [historyLoaded, setHistoryLoaded] = useState(false); const messagesEndRef = useRef(null); const bindingMap = useMemo(() => { const map = new Map(); for (const binding of bindingsResponse?.bindings ?? []) { map.set(binding.channel, binding); } return map; }, [bindingsResponse]); const currentBinding = activeChannel === "PUBLIC_POOL" ? null : (bindingMap.get(activeChannel) ?? null); const publicPool = bindingsResponse?.publicPool ?? null; const currentMessages = messagesByChannel[activeChannel]; const loadBindings = useCallback(async (): Promise => { setRefreshingBindings(true); setLoadError(null); try { const response = await listAiBindings(session); setBindingsResponse(response); } catch (error) { setLoadError(error instanceof Error ? error.message : "AI 配置加载失败"); } finally { setLoadingBindings(false); setRefreshingBindings(false); } }, [session]); useEffect(() => { void loadBindings(); }, [loadBindings]); useEffect(() => { let cancelled = false; async function loadLocalHistory(): Promise { try { const records = await listLocalAiChatSessions(session.user.id); if (cancelled) { return; } const nextMessages = createEmptyMessages(); const nextSessionIds = createEmptySessionIds(); for (const record of records) { nextMessages[record.channel] = record.messages; if (record.sessionId) { nextSessionIds[record.channel] = record.sessionId; } } setMessagesByChannel(nextMessages); setSessionIds(nextSessionIds); } finally { if (!cancelled) { setHistoryLoaded(true); } } } setHistoryLoaded(false); void loadLocalHistory(); return () => { cancelled = true; }; }, [session.user.id]); useEffect(() => { if (!historyLoaded) { return; } void Promise.all( CHANNEL_ORDER.map(async (channel) => { const messages = messagesByChannel[channel]; const sessionId = sessionIds[channel] ?? null; if (messages.length === 0 && sessionId === null) { await deleteLocalAiChatSession(session.user.id, channel); return; } await saveLocalAiChatSession({ userId: session.user.id, channel, sessionId, messages }); }) ); }, [historyLoaded, messagesByChannel, session.user.id, sessionIds]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" }); }, [activeChannel, currentMessages.length]); const sendBlockedReason = useMemo(() => { if (activeChannel === "PUBLIC_POOL") { if (!publicPool?.enabled) { return "管理员尚未开放公共 AI。"; } return null; } if (!currentBinding) { return activeChannel === "USER_KEY" ? "你还没有配置自备厂商,请先前往系统设置 > AI 配置。" : "你还没有配置 AstrBot,请先前往系统设置 > AI 配置。"; } if (!currentBinding.isEnabled) { return "当前渠道已关闭,请先在系统设置 > AI 配置中启用。"; } return null; }, [activeChannel, currentBinding, publicPool]); async function handleSendMessage(): Promise { const message = draftMessage.trim(); if (!message || sendBlockedReason || sending) { return; } const channel = activeChannel; setSending(true); setDraftMessage(""); setMessagesByChannel((current) => appendMessage(current, channel, { id: crypto.randomUUID(), role: "user", content: message, meta: formatTimeLabel() }) ); try { const localTasks = buildLocalTaskContext(await listLocalTasksByUser(session.user.id)); const response = await chatWithAi(session, { channel, message, sessionId: sessionIds[channel], localTasks }); setSessionIds((current) => ({ ...current, [channel]: response.sessionId ?? current[channel] })); setMessagesByChannel((current) => appendMessage(current, channel, { id: crypto.randomUUID(), role: "assistant", content: response.content, meta: `${CHANNEL_META[response.channel].title} · ${response.providerName}${response.model ? ` · ${response.model}` : ""}` }) ); } catch (error) { const apiError = error instanceof WebAiApiError ? error : new WebAiApiError(error instanceof Error ? error.message : "AI 请求失败"); const firstAttempt = apiError.attempts?.find((item) => item.reasonMessage); const content = firstAttempt?.reasonMessage && firstAttempt.reasonMessage !== apiError.message ? `${apiError.message}\n${firstAttempt.reasonMessage}` : apiError.message; setMessagesByChannel((current) => appendMessage(current, channel, { id: crypto.randomUUID(), role: "system", content, meta: "调用失败" }) ); } finally { setSending(false); } } function handleDraftKeyDown(event: KeyboardEvent): void { if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) { return; } event.preventDefault(); void handleSendMessage(); } return (
AI 助手

在独立页面中发起 AI 对话

聊天页面只负责问答和任务统筹。所有渠道配置统一放在系统设置中的 AI 配置页面。

{CHANNEL_META[activeChannel].title}
发送消息时会自动附带你当前未完成任务的摘要。
{currentMessages.length === 0 ? (
暂无对话记录。
你可以输入“帮我根据当前未完成任务安排今天下午的执行顺序”直接开始。
) : ( currentMessages.map((message) => (
{message.content}
{message.meta ? (
{message.meta}
) : null}
)) )}
{sendBlockedReason ? (
{sendBlockedReason}
) : null}