From ea23f6264cc6f64c7e39171f929edcd48fc63787 Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Mon, 6 Apr 2026 13:51:44 +0800 Subject: [PATCH] feat(web-ai): add channel-aware assistant panel --- apps/web/src/App.tsx | 10 +- .../src/components/ai/ai-assistant-panel.tsx | 811 ++++++++++++++++++ apps/web/src/pages/todo-shell-page.tsx | 5 +- apps/web/src/services/ai-api.ts | 155 ++++ 4 files changed, 977 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/ai/ai-assistant-panel.tsx create mode 100644 apps/web/src/services/ai-api.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c77f482..4c6384e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -48,6 +48,8 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ { key: "settings", label: "系统设置", icon: Settings } ]; +const READY_SIDEBAR_KEYS = new Set(["todo", "ai"]); + function toWebSession(payload: EmailLoginResult): WebSession { return { accessToken: payload.accessToken, @@ -151,9 +153,11 @@ function App() { {item.label} - - 即将上线 - + {READY_SIDEBAR_KEYS.has(item.key) ? null : ( + + 即将上线 + + )} )} diff --git a/apps/web/src/components/ai/ai-assistant-panel.tsx b/apps/web/src/components/ai/ai-assistant-panel.tsx new file mode 100644 index 0000000..7939ab8 --- /dev/null +++ b/apps/web/src/components/ai/ai-assistant-panel.tsx @@ -0,0 +1,811 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Bot, + CheckCircle2, + CircleAlert, + Globe2, + KeyRound, + LoaderCircle, + PlugZap, + RefreshCw, + SendHorizontal, + Settings2, + Sparkles +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + chatWithAi, + listAiBindings, + upsertAiBinding, + type UpsertWebAiBindingInput, + type WebAiBindingSummary, + type WebAiBindingsResponse, + type WebAiChannel, + WebAiApiError +} from "@/services/ai-api"; +import type { WebSession } from "@/services/session-storage"; + +type AiAssistantPanelProps = { + session: WebSession; +}; + +type AiBindingFormState = { + providerName: string; + model: string; + endpoint: string; + apiKey: string; + configId: string; + configName: string; + isEnabled: boolean; +}; + +type AiMessageRecord = { + id: string; + role: "user" | "assistant" | "system"; + content: string; + meta?: string; +}; + +type PanelNotice = { + tone: "success" | "error"; + message: string; +}; + +const CHANNEL_ORDER: WebAiChannel[] = ["USER_KEY", "ASTRBOT", "PUBLIC_POOL"]; + +const CHANNEL_META: Record< + WebAiChannel, + { + title: string; + description: string; + icon: typeof KeyRound; + accentClassName: string; + } +> = { + USER_KEY: { + title: "自备厂商", + description: "用户自己接入厂商接口", + icon: KeyRound, + accentClassName: "from-sky-500/15 via-transparent to-sky-500/5" + }, + ASTRBOT: { + title: "AstrBot", + description: "复用 AstrBot 内已接入模型", + icon: PlugZap, + accentClassName: "from-amber-500/15 via-transparent to-amber-500/5" + }, + PUBLIC_POOL: { + title: "公共 AI", + description: "使用站点管理员开放的公共通道", + icon: Globe2, + accentClassName: "from-emerald-500/15 via-transparent to-emerald-500/5" + } +}; + +function createFormState(binding?: WebAiBindingSummary | null): AiBindingFormState { + return { + providerName: binding?.providerName ?? "", + model: binding?.model ?? "", + endpoint: binding?.endpoint ?? "", + apiKey: "", + configId: binding?.configId ?? "", + configName: binding?.configName ?? "", + isEnabled: binding?.isEnabled ?? true + }; +} + +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 trimOptionalValue(value: string): string | undefined { + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function buildBindingPayload( + channel: Exclude, + formState: AiBindingFormState, + currentBinding: WebAiBindingSummary | null +): UpsertWebAiBindingInput { + return { + channel, + providerName: trimOptionalValue(formState.providerName), + model: trimOptionalValue(formState.model), + endpoint: trimOptionalValue(formState.endpoint), + configId: trimOptionalValue(formState.configId), + configName: trimOptionalValue(formState.configName), + apiKey: trimOptionalValue(formState.apiKey) ?? undefined, + isEnabled: formState.isEnabled ?? currentBinding?.isEnabled ?? true + }; +} + +function appendMessage( + records: Record, + channel: WebAiChannel, + message: AiMessageRecord +): Record { + return { + ...records, + [channel]: [...records[channel], message] + }; +} + +export function AiAssistantPanel({ session }: AiAssistantPanelProps) { + const [bindingsResponse, setBindingsResponse] = useState(null); + const [loadingBindings, setLoadingBindings] = useState(true); + const [refreshingBindings, setRefreshingBindings] = useState(false); + const [activeChannel, setActiveChannel] = useState("USER_KEY"); + const [settingsOpen, setSettingsOpen] = useState(true); + const [userKeyForm, setUserKeyForm] = useState(() => createFormState()); + const [astrbotForm, setAstrbotForm] = useState(() => createFormState()); + const [savingChannel, setSavingChannel] = useState(null); + const [panelNotice, setPanelNotice] = useState(null); + const [messagesByChannel, setMessagesByChannel] = useState< + Record + >(() => createEmptyMessages()); + const [sessionIds, setSessionIds] = useState>>(() => + createEmptySessionIds() + ); + const [draftMessage, setDraftMessage] = useState(""); + const [sending, setSending] = 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 currentMessages = messagesByChannel[activeChannel]; + const publicPool = bindingsResponse?.publicPool ?? null; + + const loadBindings = useCallback( + async (mode: "initial" | "refresh" = "refresh"): Promise => { + if (mode === "initial") { + setLoadingBindings(true); + } else { + setRefreshingBindings(true); + } + + try { + const response = await listAiBindings(session); + setBindingsResponse(response); + setUserKeyForm( + createFormState(response.bindings.find((item) => item.channel === "USER_KEY")) + ); + setAstrbotForm( + createFormState(response.bindings.find((item) => item.channel === "ASTRBOT")) + ); + } catch (error) { + setPanelNotice({ + tone: "error", + message: error instanceof Error ? error.message : "AI ??????" + }); + } finally { + setLoadingBindings(false); + setRefreshingBindings(false); + } + }, + [session] + ); + + useEffect(() => { + void loadBindings("initial"); + }, [loadBindings]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ + block: "end", + behavior: "smooth" + }); + }, [activeChannel, currentMessages.length]); + + useEffect(() => { + if (!panelNotice) { + return; + } + + const timer = window.setTimeout(() => { + setPanelNotice(null); + }, 2800); + + return () => { + window.clearTimeout(timer); + }; + }, [panelNotice]); + + const sendBlockedReason = useMemo(() => { + if (activeChannel === "PUBLIC_POOL") { + if (!publicPool?.enabled) { + return "管理员尚未开放公共 AI。"; + } + + return null; + } + + if (!currentBinding) { + return activeChannel === "USER_KEY" ? "请先保存自备厂商配置。" : "请先保存 AstrBot 配置。"; + } + + if (!currentBinding.isEnabled) { + return "当前渠道已关闭,请先启用后再发起对话。"; + } + + return null; + }, [activeChannel, currentBinding, publicPool]); + + const channelStatusText = useMemo(() => { + if (activeChannel === "PUBLIC_POOL") { + return publicPool?.enabled ? "管理员已开放" : "当前不可用"; + } + + if (!currentBinding) { + return "尚未配置"; + } + + return currentBinding.isEnabled ? "已配置并启用" : "已配置但停用"; + }, [activeChannel, currentBinding, publicPool]); + + async function handleSaveChannel(channel: Exclude): Promise { + const formState = channel === "USER_KEY" ? userKeyForm : astrbotForm; + const binding = bindingMap.get(channel) ?? null; + + try { + setSavingChannel(channel); + await upsertAiBinding(session, buildBindingPayload(channel, formState, binding)); + setPanelNotice({ + tone: "success", + message: channel === "USER_KEY" ? "自备厂商配置已保存。" : "AstrBot 配置已保存。" + }); + if (channel === "USER_KEY") { + setUserKeyForm((current) => ({ + ...current, + apiKey: "" + })); + } else { + setAstrbotForm((current) => ({ + ...current, + apiKey: "" + })); + } + await loadBindings("refresh"); + } catch (error) { + setPanelNotice({ + tone: "error", + message: error instanceof Error ? error.message : "AI 配置保存失败" + }); + } finally { + setSavingChannel(null); + } + } + + 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 response = await chatWithAi(session, { + channel, + message, + sessionId: sessionIds[channel] + }); + + 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 firstFailedAttempt = apiError.attempts?.find((item) => item.reasonMessage); + const content = + firstFailedAttempt?.reasonMessage && firstFailedAttempt.reasonMessage !== apiError.message + ? `${apiError.message}\n${firstFailedAttempt.reasonMessage}` + : apiError.message; + + setMessagesByChannel((current) => + appendMessage(current, channel, { + id: crypto.randomUUID(), + role: "system", + content, + meta: "调用失败" + }) + ); + } finally { + setSending(false); + } + } + + function renderChannelButton(channel: WebAiChannel) { + const channelMeta = CHANNEL_META[channel]; + const ChannelIcon = channelMeta.icon; + const selected = activeChannel === channel; + const binding = channel === "PUBLIC_POOL" ? null : (bindingMap.get(channel) ?? null); + const enabled = + channel === "PUBLIC_POOL" ? Boolean(publicPool?.enabled) : Boolean(binding?.isEnabled); + const statusLabel = + channel === "PUBLIC_POOL" + ? publicPool?.enabled + ? "可使用" + : "未开放" + : binding + ? enabled + ? "已启用" + : "已停用" + : "未配置"; + + return ( + + ); + } + + function renderNotice() { + if (!panelNotice) { + return null; + } + + return ( +
+ {panelNotice.tone === "success" ? ( + + ) : ( + + )} + {panelNotice.message} +
+ ); + } + + function renderPrivateConfigForm(channel: Exclude) { + const formState = channel === "USER_KEY" ? userKeyForm : astrbotForm; + const setFormState = channel === "USER_KEY" ? setUserKeyForm : setAstrbotForm; + const binding = bindingMap.get(channel) ?? null; + + return ( +
+
+ + + +
+ + + + {channel === "ASTRBOT" ? ( +
+ + + +
+ ) : null} + + + + + +
+

+ {channel === "USER_KEY" + ? "当前自备厂商通道按用户单独保存,适合个人独享密钥。" + : "AstrBot 通道按用户单独保存,可直接复用你在 AstrBot 中维护的模型配置。"} +

+ +
+
+ ); + } + + function renderPublicPoolCard() { + return ( +
+
+
+
+
+ {publicPool?.providerName || "公共 AI"} +
+
+ {publicPool?.model ? `默认模型:${publicPool.model}` : "管理员尚未设置默认模型"} +
+
+ + {publicPool?.enabled ? "可用" : "不可用"} + +
+
+ 公共 AI 由管理后台统一维护,普通用户仅可选择使用,不可查看或修改密钥。 +
+
+
+ ); + } + + return ( +
+
+
+
+
+ + AI 助手 +
+

+ 三路通道,按用户独立配置 +

+

+ 你可以随时切换 AstrBot、自备厂商与公共 AI 进行问答和任务统筹。 +

+
+ +
+
+ +
+ {renderNotice()} + +
+ {CHANNEL_ORDER.map((channel) => renderChannelButton(channel))} +
+ +
+
+
+ + + +
+
+ {CHANNEL_META[activeChannel].title} +
+
{channelStatusText}
+
+
+ +
+
+ + {settingsOpen ? ( +
+ {loadingBindings ? ( +
+ + 正在加载 AI 配置... +
+ ) : activeChannel === "PUBLIC_POOL" ? ( + renderPublicPoolCard() + ) : ( + renderPrivateConfigForm(activeChannel) + )} +
+ ) : null} + +
+
+
对话记录
+
+ 当前渠道:{CHANNEL_META[activeChannel].title} +
+
+ +
+ {currentMessages.length === 0 ? ( +
+
还没有对话记录。
+
+ 发送一句话试试看,例如“帮我根据当前未完成任务安排今天下午的执行顺序”。 +
+
+ ) : ( + currentMessages.map((message) => ( +
+
{message.content}
+ {message.meta ? ( +
+ {message.meta} +
+ ) : null} +
+ )) + )} +
+
+ +
+ {sendBlockedReason ? ( +
+ {sendBlockedReason} +
+ ) : null} + +