From fbff622249ab8ae6406a60b6747208fc4515cd01 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 9 Oct 2026 15:52:35 +0800 Subject: [PATCH] feat(web): switch models and agents from chat composer (#497) --- .../components/ai-sidebar/AiAgentSelector.tsx | 142 ++++++++++++++++++ .../src/components/ai-sidebar/AiSidebar.tsx | 99 +++++++----- .../ai-sidebar/InfographicSidebarSession.tsx | 47 +++--- .../ai-sidebar/SidebarAgentModeStatus.tsx | 32 +--- .../dialogs/AiAssistantDialog.test.mjs | 5 +- .../settings/DesktopAcpAgentCard.tsx | 13 ++ apps/web/src/lib/desktop-acp.test.mjs | 45 ++++++ apps/web/src/lib/desktop-acp.ts | 27 ++++ packages/shared/src/i18n/en-US.ts | 9 ++ packages/shared/src/i18n/ja.ts | 9 ++ packages/shared/src/i18n/pl.ts | 9 ++ packages/shared/src/i18n/zh-CN.ts | 9 ++ 12 files changed, 354 insertions(+), 92 deletions(-) create mode 100644 apps/web/src/components/ai-sidebar/AiAgentSelector.tsx diff --git a/apps/web/src/components/ai-sidebar/AiAgentSelector.tsx b/apps/web/src/components/ai-sidebar/AiAgentSelector.tsx new file mode 100644 index 00000000..a24691ba --- /dev/null +++ b/apps/web/src/components/ai-sidebar/AiAgentSelector.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, Loader2 } from "lucide-react"; +import { Link } from "react-router"; +import { WORKSPACE_SETTINGS_PATH } from "@/hooks/useWorkspaceRoute"; +import { useTranslation } from "react-i18next"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { api } from "@/lib/api"; +import { + AI_SIDEBAR_ADAPTER_PATH_KEY, aiSidebarAdapterFromStorage, + desktopAcpAvailable, listDesktopAcpAdapters, probeDesktopAcpAdapter, selectAiSidebarAgent, + type AiSidebarSource, type DesktopAcpAdapterId, +} from "@/lib/desktop-acp"; +import { aiErrorMessage, formatProviderOrdinal, isLegacyProviderDisplayName } from "../settings/ai-provider-options"; +import { resolveBuiltinAgentModel } from "./builtin-agent-model"; + +export function AiAgentSelector({ source, adapterId, disabled, onPendingChange, noteContext = false }: { + source: AiSidebarSource; + adapterId: DesktopAcpAdapterId | null; + disabled: boolean; + noteContext?: boolean; + onPendingChange: (pending: boolean) => void; +}) { + const { t, i18n } = useTranslation(); + const locale = i18n.resolvedLanguage ?? i18n.language; + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const settings = useQuery({ queryKey: ["ai-settings", locale], queryFn: () => api.getAiSettings(locale), staleTime: 30_000 }); + const desktop = desktopAcpAvailable(); + const agents = useQuery({ + queryKey: ["ai-sidebar-adapters"], + queryFn: async () => { + const listed = await listDesktopAcpAdapters(); + const configured = aiSidebarAdapterFromStorage("antigravity", window.localStorage.getItem(AI_SIDEBAR_ADAPTER_PATH_KEY)); + if (configured?.id === "antigravity" && configured.path) { + const checked = await probeDesktopAcpAdapter(configured); + return [...listed.filter((agent) => agent.id !== checked.id), checked]; + } + return listed; + }, + enabled: desktop && open, + staleTime: 0, + }); + const mutation = useMutation({ + mutationFn: async (value: string) => { + if (value.startsWith("agent:")) { + const id = value.slice(6) as DesktopAcpAdapterId; + if (!agents.data?.some((agent) => agent.id === id && agent.state === "available")) throw new Error(t("aiAssistant.agentSource.switchUnavailable")); + selectAiSidebarAgent("local", id); + } else { + if (value !== "builtin" && value.slice(6) !== settings.data?.defaultModelId) { + const updated = await api.updateDefaultAiModel(value.slice(6)); + queryClient.setQueryData(["ai-settings", locale], updated); + await queryClient.invalidateQueries({ queryKey: ["ai-settings"] }); + } + selectAiSidebarAgent("builtin"); + } + }, + onSuccess: () => setOpen(false), + }); + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + const builtin = resolveBuiltinAgentModel(settings.data); + const provider = settings.data?.providers.find((item) => item.models.some((model) => model.id === settings.data?.defaultModelId)); + const modelLabel = [provider?.displayName, builtin?.label].filter(Boolean).join(" · ") || t("aiModel.noDefaultModel"); + const label = source === "local" + ? (adapterId ? t(`aiAssistant.agentSource.${adapterId}`) : t("aiAssistant.agentSource.local")) + : `${t("aiAssistant.agentSource.builtin")} · ${modelLabel}`; + const buttonLabel = source === "local" ? label : builtin?.label ?? t("aiModel.noDefaultModel"); + const localHint = t(noteContext ? "aiAssistant.agentSource.switchNoteAgentHint" : "aiAssistant.agentSource.newAgentThread"); + const selected = source === "local" ? `agent:${adapterId}` : settings.data?.defaultModelId ? `model:${settings.data.defaultModelId}` : "builtin"; + const providers = (settings.data?.providers ?? []).map((provider, index) => { + const name = !provider.displayName || isLegacyProviderDisplayName(provider.displayName, provider.provider) + ? t("aiModel.defaultProviderName", { ordinal: formatProviderOrdinal(index + 1, locale) }) : provider.displayName; + return { ...provider, name }; + }).filter((provider) => provider.models.length); + const localAgents = agents.data ?? []; + const choose = (value: string) => { + if (disabled || mutation.isPending) return; + if (value === selected) { setOpen(false); return; } + onPendingChange(true); + void mutation.mutateAsync(value).catch(() => undefined).finally(() => onPendingChange(false)); + }; + + return ( +
+ { + if (mutation.isPending) return; + setOpen(next); + if (next) { mutation.reset(); void settings.refetch(); } + }}> + + + + + {settings.isError || mutation.isError ?

{aiErrorMessage(mutation.error ?? settings.error, t("aiAssistant.agentSource.switchUnavailable"), t("aiModel.encryptionKeyMissing"), t("aiModel.savedCredentialsUnavailable"))}

: null} +
+ + {source === "local" ? event.preventDefault()}>{t("aiAssistant.agentSource.builtin")} : null} + {settings.isPending ?

{t("common.loading")}

: null} + {providers.map((provider) => ( +
+ {t("aiAssistant.agentSource.builtin")} · {provider.name} + {provider.models.map((model) => event.preventDefault()}> + {model.displayName || model.modelId}{!provider.isEnabled || provider.credentialsUnavailable ? {t("aiAssistant.agentSource.optionUnavailable")} : null} + )} +
+ ))} + {!settings.isPending && !providers.length ?

{t("aiAssistant.agentSource.noModels")}

: null} + {desktop ? <> + + {t("aiAssistant.agentSource.local")} +

{localHint}

+ {agents.isPending ?

{t("common.loading")}

: null} + {agents.isError ?

{t("aiAssistant.agentSource.switchUnavailable")}

: null} + {localAgents.map((agent) => event.preventDefault()}> + {t(`aiAssistant.agentSource.${agent.id}`)}{t(`aiAssistant.agentSource.states.${agent.state}`)} + )} + {!agents.isPending && !agents.isError && !localAgents.length ?

{t("aiAssistant.agentSource.switchUnavailable")}

: null} + : null} +
+
+ + + {t("aiAssistant.agentSource.configure")} + +
+
+
+ ); +} diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index 5189e878..abfde4b7 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type ReactNode } from "react"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; import { useTranslation } from "react-i18next"; @@ -48,6 +48,7 @@ import { } from "@/lib/ai-attachments"; import { companionLocale } from "@/lib/companion-locale"; import { + AI_SIDEBAR_SELECTION_EVENT, AI_SIDEBAR_LOCAL_THREAD_KEY, AI_SIDEBAR_LOCAL_THREADS_KEY, AI_SIDEBAR_OPEN_KEY, @@ -58,6 +59,7 @@ import { readAiSidebarAdapter, readAiSidebarSource, subscribeDesktopAcp, + startsNewLocalAgentThread, type DesktopAcpEvent, } from "@/lib/desktop-acp"; import { @@ -84,7 +86,7 @@ import { CompanionQuestionForm } from "../CompanionQuestionForm"; import { CompanionCreatedNoteLinks } from "../CompanionCreatedNoteLinks"; import { AiSidebarMessage } from "./AiSidebarMessage"; import { AiSidebarLocalProcess } from "./AiSidebarLocalProcess"; -import { BuiltinAgentStatus } from "./BuiltinAgentStatus"; +import { AiAgentSelector } from "./AiAgentSelector"; import { InfographicSidebarSession, type InfographicSidebarController } from "./InfographicSidebarSession"; import { memoIdFromSidebarLinkEvent } from "./sidebar-note-links"; @@ -565,6 +567,7 @@ function SidebarComposer({ onStop, skillPrompt, focusToken, + aiSelector, }: { attachments: PendingAttachment[]; attachmentError: string | null; @@ -580,6 +583,7 @@ function SidebarComposer({ onStop: () => void; skillPrompt: (id: SkillId, rest?: string) => string; focusToken: number; + aiSelector: ReactNode; }) { const { t } = useTranslation(); const { textInput } = usePromptInputController(); @@ -665,7 +669,7 @@ function SidebarComposer({ onPaste={onPaste} /> - + @@ -699,20 +703,23 @@ function SidebarComposer({ ) : null} - {busy ? ( - - ) : ( - - - - )} +
+ {aiSelector} + {busy ? ( + + ) : ( + + + + )} +
(null); const [conflicts, setConflicts] = useState>({}); const [busy, setBusy] = useState(false); + const [selectionPending, setSelectionPending] = useState(false); + const selectionPendingRef = useRef(false); + const previousAgent = useRef({ source, adapterId: localAdapterId }); const [acting, setActing] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -1000,18 +1010,32 @@ function AiSidebarSession({ useEffect(() => { const syncSource = () => { - setSource(readAiSidebarSource()); - setLocalAdapterId(readLocalAdapter()?.id ?? null); + if (locked.current) return; + const nextSource = readAiSidebarSource(); + const nextAdapter = readLocalAdapter()?.id ?? null; + const previous = previousAgent.current; + if (startsNewLocalAgentThread(previous, { source: nextSource, adapterId: nextAdapter })) { + const id = createClientUuid(); + setLocalThreadId(id); + localThreadIdRef.current = id; + writeStorage(AI_SIDEBAR_LOCAL_THREAD_KEY, id); + } + previousAgent.current = { source: nextSource, adapterId: nextAdapter }; + setSource(nextSource); + setLocalAdapterId(nextAdapter); + if (previous.source !== nextSource || previous.adapterId !== nextAdapter) setError(null); }; syncSource(); if (!open) return; window.addEventListener("focus", syncSource); window.addEventListener("storage", syncSource); + window.addEventListener(AI_SIDEBAR_SELECTION_EVENT, syncSource); return () => { window.removeEventListener("focus", syncSource); window.removeEventListener("storage", syncSource); + window.removeEventListener(AI_SIDEBAR_SELECTION_EVENT, syncSource); }; - }, [open]); + }, [open, busy]); useEffect(() => { if (!companionAvailable || source === "local") { @@ -1103,7 +1127,7 @@ function AiSidebarSession({ const launch = useCallback(async (message: string, options?: { includeCurrentNote?: boolean; readOnly?: boolean }): Promise => { const text = message.trim(); - if (!text || locked.current) throw new Error("busy"); + if (!text || locked.current || selectionPendingRef.current) throw new Error("busy"); const useCurrentNote = options?.includeCurrentNote ?? includeCurrentNote; const focusAtSend = focusRef.current; const mode = readAiSidebarSource(); @@ -1525,28 +1549,10 @@ function AiSidebarSession({ if (companionThreads.some((thread) => thread.id === threadId)) rememberThread(createClientUuid()); }} /> - {source === "local" ? ( - - - - - {localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")} - - - -

{localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}

-

{t("aiAssistant.agentSource.localHint")}

-
-
-
- ) : } ) : null} - {session.generating ? ( - - ) : ( - - - - )} +
+ + {session.generating ? ( + + ) : ( + + + + )} +
); @@ -119,6 +122,7 @@ export function InfographicSidebarSession({ onOpenNote?: (memoId: string, notebookId: string) => void; }) { const { t, i18n } = useTranslation(); + const [selectionPending, setSelectionPending] = useState(false); const title = noteTitle?.trim() || t("infographic.name"); const openLinkedNote = (event: MouseEvent) => { const linkedId = memoIdFromSidebarLinkEvent(event); @@ -139,7 +143,6 @@ export function InfographicSidebarSession({