feat(web): switch models and agents from chat composer (#497)
This commit is contained in:
1 parent
cc18b4329f
commit
fbff622249
12 files changed
+354
-92
No files matched your search
@@ -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 (
|
||||
<div className="min-w-0 max-w-44 flex-1">
|
||||
<DropdownMenu open={open} onOpenChange={(next) => {
|
||||
if (mutation.isPending) return;
|
||||
setOpen(next);
|
||||
if (next) { mutation.reset(); void settings.refetch(); }
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" disabled={disabled || mutation.isPending}
|
||||
aria-label={`${t("aiAssistant.agentSource.switch")} · ${label}`}
|
||||
className="flex w-full min-w-0 items-center gap-1 rounded-full px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:opacity-50">
|
||||
<span className="truncate">{buttonLabel}</span>
|
||||
{mutation.isPending ? <Loader2 className="size-3 shrink-0 animate-spin" /> : <ChevronDown className="size-3 shrink-0" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="end" className="w-80 max-w-[calc(100vw-24px)]">
|
||||
{settings.isError || mutation.isError ? <p role="alert" className="px-2 py-1 text-xs text-rose-600">{aiErrorMessage(mutation.error ?? settings.error, t("aiAssistant.agentSource.switchUnavailable"), t("aiModel.encryptionKeyMissing"), t("aiModel.savedCredentialsUnavailable"))}</p> : null}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<DropdownMenuRadioGroup value={selected} onValueChange={choose}>
|
||||
{source === "local" ? <DropdownMenuRadioItem value="builtin" disabled={mutation.isPending} onSelect={(event) => event.preventDefault()}>{t("aiAssistant.agentSource.builtin")}</DropdownMenuRadioItem> : null}
|
||||
{settings.isPending ? <p className="px-2 py-2 text-xs">{t("common.loading")}</p> : null}
|
||||
{providers.map((provider) => (
|
||||
<div key={provider.id}>
|
||||
<DropdownMenuLabel>{t("aiAssistant.agentSource.builtin")} · {provider.name}</DropdownMenuLabel>
|
||||
{provider.models.map((model) => <DropdownMenuRadioItem key={model.id} value={`model:${model.id}`}
|
||||
disabled={mutation.isPending || settings.data?.readOnly || !settings.data?.encryptionConfigured || !provider.isEnabled || provider.credentialsUnavailable}
|
||||
onSelect={(event) => event.preventDefault()}>
|
||||
<span className="min-w-0 break-words">{model.displayName || model.modelId}{!provider.isEnabled || provider.credentialsUnavailable ? <span className="ml-2 text-[10px] text-slate-500">{t("aiAssistant.agentSource.optionUnavailable")}</span> : null}</span>
|
||||
</DropdownMenuRadioItem>)}
|
||||
</div>
|
||||
))}
|
||||
{!settings.isPending && !providers.length ? <p className="px-2 py-2 text-xs text-slate-500">{t("aiAssistant.agentSource.noModels")}</p> : null}
|
||||
{desktop ? <>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t("aiAssistant.agentSource.local")}</DropdownMenuLabel>
|
||||
<p className="px-2 pb-1 text-[11px] text-slate-500">{localHint}</p>
|
||||
{agents.isPending ? <p className="px-2 py-2 text-xs">{t("common.loading")}</p> : null}
|
||||
{agents.isError ? <p role="alert" className="px-2 py-2 text-xs text-rose-600">{t("aiAssistant.agentSource.switchUnavailable")}</p> : null}
|
||||
{localAgents.map((agent) => <DropdownMenuRadioItem key={agent.id} value={`agent:${agent.id}`} disabled={mutation.isPending || agents.isFetching || agent.state !== "available"} onSelect={(event) => event.preventDefault()}>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap justify-between gap-x-2"><span>{t(`aiAssistant.agentSource.${agent.id}`)}</span><span className="text-[10px] text-slate-500">{t(`aiAssistant.agentSource.states.${agent.state}`)}</span></span>
|
||||
</DropdownMenuRadioItem>)}
|
||||
{!agents.isPending && !agents.isError && !localAgents.length ? <p className="px-2 py-2 text-xs text-slate-500">{t("aiAssistant.agentSource.switchUnavailable")}</p> : null}
|
||||
</> : null}
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild disabled={mutation.isPending}>
|
||||
<Link to={`${WORKSPACE_SETTINGS_PATH}?tab=ai`}>{t("aiAssistant.agentSource.configure")}</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
<PromptInputFooter className="px-2.5 pb-2.5 pt-0">
|
||||
<PromptInputTools>
|
||||
<PromptInputTools className="shrink-0">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -699,20 +703,23 @@ function SidebarComposer({
|
||||
</Button>
|
||||
) : null}
|
||||
</PromptInputTools>
|
||||
{busy ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={onStop}>
|
||||
{t("aiAssistant.sidebar.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<PromptInputSubmit
|
||||
aria-label={t("companion.send")}
|
||||
className="rounded-full border-slate-900 bg-slate-900 text-slate-50 hover:border-slate-800 hover:bg-slate-800 disabled:border-slate-100 disabled:bg-slate-100 disabled:text-slate-300"
|
||||
disabled={locked || !draft.trim()}
|
||||
variant="solid"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</PromptInputSubmit>
|
||||
)}
|
||||
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||
{aiSelector}
|
||||
{busy ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={onStop}>
|
||||
{t("aiAssistant.sidebar.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<PromptInputSubmit
|
||||
aria-label={t("companion.send")}
|
||||
className="rounded-full border-slate-900 bg-slate-900 text-slate-50 hover:border-slate-800 hover:bg-slate-800 disabled:border-slate-100 disabled:bg-slate-100 disabled:text-slate-300"
|
||||
disabled={locked || !draft.trim()}
|
||||
variant="solid"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</PromptInputSubmit>
|
||||
)}
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
<input
|
||||
@@ -777,6 +784,9 @@ function AiSidebarSession({
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<Record<string, string>>({});
|
||||
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<string | null>(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<string> => {
|
||||
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" ? (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="ml-auto min-w-0 max-w-[46%] shrink truncate rounded-sm text-right text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
data-ai-local-agent=""
|
||||
tabIndex={0}
|
||||
>
|
||||
{localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>{localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}</p>
|
||||
<p className="mt-1">{t("aiAssistant.agentSource.localHint")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : <BuiltinAgentStatus />}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
className="ml-auto shrink-0"
|
||||
variant="ghost"
|
||||
title={t("aiAssistant.sidebar.close")}
|
||||
aria-label={t("aiAssistant.sidebar.close")}
|
||||
@@ -1716,7 +1722,7 @@ function AiSidebarSession({
|
||||
attachments={attachments}
|
||||
attachmentError={attachmentError}
|
||||
busy={running}
|
||||
locked={acting}
|
||||
locked={acting || selectionPending}
|
||||
includeCurrentNote={includeCurrentNote}
|
||||
currentNoteAvailable={Boolean(noteTitle?.trim() || contentMarkdown?.trim())}
|
||||
onIncludeCurrentNoteChange={setIncludeCurrentNote}
|
||||
@@ -1726,6 +1732,17 @@ function AiSidebarSession({
|
||||
onLaunch={launch}
|
||||
onStop={() => { void stop(); }}
|
||||
skillPrompt={skillPrompt}
|
||||
aiSelector={
|
||||
<AiAgentSelector
|
||||
source={source}
|
||||
adapterId={localAdapterId}
|
||||
disabled={running || acting || loading}
|
||||
onPendingChange={(pending) => {
|
||||
selectionPendingRef.current = pending;
|
||||
setSelectionPending(pending);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
focusToken={composerFocusToken}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, PanelRightClose, Sparkles, Undo2 } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import type { InfographicConversationTurn } from "@edgeever/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "@/components/ai-elements/conversation";
|
||||
@@ -47,7 +47,7 @@ export type InfographicSidebarController = {
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
function InfographicSidebarComposer({ session }: { session: InfographicSidebarController }) {
|
||||
function InfographicSidebarComposer({ session, selectionPending, onPendingChange }: { session: InfographicSidebarController; selectionPending: boolean; onPendingChange: (pending: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { textInput } = usePromptInputController();
|
||||
const draft = textInput.value.trim();
|
||||
@@ -56,39 +56,42 @@ function InfographicSidebarComposer({ session }: { session: InfographicSidebarCo
|
||||
inputGroupClassName="rounded-[22px] border-slate-200 bg-card shadow-sm has-[[data-slot=input-group-control]:focus-visible]:ring-0"
|
||||
onSubmit={async ({ text }) => {
|
||||
const raw = text.trim().slice(0, 1000);
|
||||
if (!raw || session.generating) throw new Error("empty");
|
||||
if (!raw || session.generating || selectionPending) throw new Error("empty");
|
||||
await session.onSubmit(raw);
|
||||
}}
|
||||
>
|
||||
<PromptInputTextarea
|
||||
className="min-h-20 px-4 pb-2 pt-4 text-[13px] leading-5 md:text-[13px]"
|
||||
disabled={session.generating}
|
||||
disabled={session.generating || selectionPending}
|
||||
maxLength={1000}
|
||||
placeholder={t(session.hasGraphic ? "infographic.refinePrompt" : "infographic.prompt")}
|
||||
/>
|
||||
<PromptInputFooter className="px-2.5 pb-2.5 pt-0">
|
||||
<PromptInputTools>
|
||||
<PromptInputTools className="shrink-0">
|
||||
{session.canUndo ? (
|
||||
<Button type="button" size="sm" variant="outline" disabled={session.generating} onClick={session.onUndo}>
|
||||
<Button type="button" size="sm" variant="outline" disabled={session.generating || selectionPending} onClick={session.onUndo}>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" />
|
||||
{t("infographic.undoGeneration")}
|
||||
</Button>
|
||||
) : null}
|
||||
</PromptInputTools>
|
||||
{session.generating ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={session.onStop}>
|
||||
{t("aiAssistant.sidebar.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<PromptInputSubmit
|
||||
aria-label={t(session.hasGraphic ? "infographic.applyRefinement" : "infographic.generate")}
|
||||
className="rounded-full border-slate-900 bg-slate-900 text-slate-50 hover:border-slate-800 hover:bg-slate-800 disabled:border-slate-100 disabled:bg-slate-100 disabled:text-slate-300"
|
||||
disabled={!draft}
|
||||
variant="solid"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</PromptInputSubmit>
|
||||
)}
|
||||
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||
<SidebarAgentModeStatus disabled={session.generating} onPendingChange={onPendingChange} />
|
||||
{session.generating ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={session.onStop}>
|
||||
{t("aiAssistant.sidebar.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<PromptInputSubmit
|
||||
aria-label={t(session.hasGraphic ? "infographic.applyRefinement" : "infographic.generate")}
|
||||
className="rounded-full border-slate-900 bg-slate-900 text-slate-50 hover:border-slate-800 hover:bg-slate-800 disabled:border-slate-100 disabled:bg-slate-100 disabled:text-slate-300"
|
||||
disabled={!draft || selectionPending}
|
||||
variant="solid"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</PromptInputSubmit>
|
||||
)}
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
);
|
||||
@@ -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<HTMLElement>) => {
|
||||
const linkedId = memoIdFromSidebarLinkEvent(event);
|
||||
@@ -139,7 +143,6 @@ export function InfographicSidebarSession({
|
||||
<Sparkles className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{title}</span>
|
||||
</span>
|
||||
<SidebarAgentModeStatus />
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
@@ -199,7 +202,7 @@ export function InfographicSidebarSession({
|
||||
{!session.readOnly ? (
|
||||
<div className="shrink-0 p-3">
|
||||
<PromptInputProvider>
|
||||
<InfographicSidebarComposer session={session} />
|
||||
<InfographicSidebarComposer session={session} selectionPending={selectionPending} onPendingChange={setSelectionPending} />
|
||||
</PromptInputProvider>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { readAiSidebarAdapter, readAiSidebarSource, type AiSidebarSource } from "@/lib/desktop-acp";
|
||||
import { BuiltinAgentStatus } from "./BuiltinAgentStatus";
|
||||
import { AI_SIDEBAR_SELECTION_EVENT, readAiSidebarAdapter, readAiSidebarSource, type AiSidebarSource } from "@/lib/desktop-acp";
|
||||
import { AiAgentSelector } from "./AiAgentSelector";
|
||||
|
||||
const chipClassName = "ml-auto min-w-0 max-w-[46%] shrink-0 truncate rounded-sm text-right text-[11px] leading-4 text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400";
|
||||
|
||||
export function SidebarAgentModeStatus() {
|
||||
const { t } = useTranslation();
|
||||
export function SidebarAgentModeStatus({ disabled, onPendingChange }: { disabled: boolean; onPendingChange: (pending: boolean) => void }) {
|
||||
const [source, setSource] = useState<AiSidebarSource>(readAiSidebarSource);
|
||||
const [adapterId, setAdapterId] = useState(() => readAiSidebarAdapter()?.id ?? null);
|
||||
|
||||
@@ -19,30 +14,15 @@ export function SidebarAgentModeStatus() {
|
||||
sync();
|
||||
window.addEventListener("focus", sync);
|
||||
window.addEventListener("storage", sync);
|
||||
window.addEventListener(AI_SIDEBAR_SELECTION_EVENT, sync);
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
return () => {
|
||||
window.removeEventListener("focus", sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
window.removeEventListener(AI_SIDEBAR_SELECTION_EVENT, sync);
|
||||
document.removeEventListener("visibilitychange", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (source !== "local") return <BuiltinAgentStatus />;
|
||||
|
||||
const label = adapterId ? t(`aiAssistant.agentSource.${adapterId}`) : t("aiAssistant.sidebar.localStatus");
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={chipClassName} data-ai-local-agent="" tabIndex={0} aria-label={label}>
|
||||
{label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs" side="bottom">
|
||||
<p>{label}</p>
|
||||
<p className="mt-1">{t("aiAssistant.agentSource.localHint")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
return <AiAgentSelector noteContext source={source} adapterId={adapterId} disabled={disabled} onPendingChange={onPendingChange} />;
|
||||
}
|
||||
@@ -16,15 +16,14 @@ describe("AI assistant modes", () => {
|
||||
expect(sidebar).not.toContain("hidden w-0");
|
||||
expect(sidebar).toContain("props.infographic ? (");
|
||||
expect(sidebar).toContain("<InfographicSidebarSession");
|
||||
expect(sidebar).toContain("<BuiltinAgentStatus");
|
||||
expect(sidebar).toContain("<AiAgentSelector");
|
||||
const infographic = readFileSync(new URL("../ai-sidebar/InfographicSidebarSession.tsx", import.meta.url), "utf8");
|
||||
const mode = readFileSync(new URL("../ai-sidebar/SidebarAgentModeStatus.tsx", import.meta.url), "utf8");
|
||||
const status = readFileSync(new URL("../ai-sidebar/BuiltinAgentStatus.tsx", import.meta.url), "utf8");
|
||||
const pane = readFileSync(new URL("../InfographicEditorPane.tsx", import.meta.url), "utf8");
|
||||
expect(infographic).toContain("<SidebarAgentModeStatus");
|
||||
expect(mode).toContain("readAiSidebarSource");
|
||||
expect(mode).toContain("<BuiltinAgentStatus");
|
||||
expect(mode).toContain('data-ai-local-agent=""');
|
||||
expect(mode).toContain("<AiAgentSelector");
|
||||
expect(pane).toContain('readAiSidebarSource() === "local"');
|
||||
expect(pane).toContain("noteAccess: false");
|
||||
expect(status).toContain('data-ai-builtin-agent=""');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AI_SIDEBAR_ADAPTER_KEY,
|
||||
AI_SIDEBAR_ADAPTER_PATH_KEY,
|
||||
AI_SIDEBAR_SOURCE_KEY,
|
||||
AI_SIDEBAR_SELECTION_EVENT,
|
||||
displayedDesktopAcpAdapter,
|
||||
listDesktopAcpAdapters,
|
||||
installDesktopAcpAdapter,
|
||||
@@ -99,6 +100,17 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => {
|
||||
setAdapterId(readAdapterId());
|
||||
setAdapterPath(localStorage.getItem(AI_SIDEBAR_ADAPTER_PATH_KEY) ?? "");
|
||||
setReady(true);
|
||||
const sync = () => {
|
||||
setSource(bridge && readSource() === "local" ? "local" : "builtin");
|
||||
setAdapterId(readAdapterId());
|
||||
setAdapterPath(localStorage.getItem(AI_SIDEBAR_ADAPTER_PATH_KEY) ?? "");
|
||||
};
|
||||
window.addEventListener(AI_SIDEBAR_SELECTION_EVENT, sync);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => {
|
||||
window.removeEventListener(AI_SIDEBAR_SELECTION_EVENT, sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}, [bridge]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,6 +118,7 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => {
|
||||
localStorage.setItem(AI_SIDEBAR_SOURCE_KEY, source);
|
||||
localStorage.setItem(AI_SIDEBAR_ADAPTER_KEY, adapterId);
|
||||
localStorage.setItem(AI_SIDEBAR_ADAPTER_PATH_KEY, adapterPath);
|
||||
window.dispatchEvent(new Event(AI_SIDEBAR_SELECTION_EVENT));
|
||||
}, [adapterId, adapterPath, ready, source]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -30,3 +30,48 @@ test("a prompt-time login failure takes priority over an earlier successful sess
|
||||
const earlierProbe = { id: "grokBuild", label: "Grok Build", state: "available" };
|
||||
expect(displayedDesktopAcpAdapter({ id: "grokBuild", path: "", listed: [current], probed: earlierProbe })).toBe(current);
|
||||
});
|
||||
|
||||
test("switching into an external agent starts a fresh chat without changing built-in chats", async () => {
|
||||
const { startsNewLocalAgentThread } = await import("./desktop-acp.ts");
|
||||
const builtin = { source: "builtin", adapterId: "codex" };
|
||||
const codex = { source: "local", adapterId: "codex" };
|
||||
const claude = { source: "local", adapterId: "claudeCode" };
|
||||
expect(startsNewLocalAgentThread(builtin, codex)).toBe(true);
|
||||
expect(startsNewLocalAgentThread(codex, claude)).toBe(true);
|
||||
expect(startsNewLocalAgentThread(codex, codex)).toBe(false);
|
||||
expect(startsNewLocalAgentThread(codex, builtin)).toBe(false);
|
||||
});
|
||||
|
||||
test("agent selection reuses preference keys, preserves the custom path and notifies only after saving", async () => {
|
||||
const { selectAiSidebarAgent, AI_SIDEBAR_SELECTION_EVENT, AI_SIDEBAR_SOURCE_KEY, AI_SIDEBAR_ADAPTER_KEY, AI_SIDEBAR_ADAPTER_PATH_KEY } = await import("./desktop-acp.ts");
|
||||
const previousWindow = globalThis.window;
|
||||
const data = new Map([[AI_SIDEBAR_SOURCE_KEY, "builtin"], [AI_SIDEBAR_ADAPTER_KEY, "codex"], [AI_SIDEBAR_ADAPTER_PATH_KEY, "/custom/antigravity"]]);
|
||||
const snapshots = [];
|
||||
let fail = false;
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem: key => data.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
if (fail && key === AI_SIDEBAR_SOURCE_KEY) { fail = false; throw new Error("storage full"); }
|
||||
data.set(key, value);
|
||||
},
|
||||
removeItem: key => data.delete(key),
|
||||
},
|
||||
dispatchEvent: event => snapshots.push({ type: event.type, source: data.get(AI_SIDEBAR_SOURCE_KEY), adapter: data.get(AI_SIDEBAR_ADAPTER_KEY) }),
|
||||
};
|
||||
try {
|
||||
selectAiSidebarAgent("local", "claudeCode");
|
||||
expect(snapshots).toEqual([{ type: AI_SIDEBAR_SELECTION_EVENT, source: "local", adapter: "claudeCode" }]);
|
||||
expect(data.get(AI_SIDEBAR_ADAPTER_PATH_KEY)).toBe("/custom/antigravity");
|
||||
selectAiSidebarAgent("builtin");
|
||||
expect(data.get(AI_SIDEBAR_ADAPTER_KEY)).toBe("claudeCode");
|
||||
fail = true;
|
||||
expect(() => selectAiSidebarAgent("local", "codex")).toThrow("storage full");
|
||||
expect(data.get(AI_SIDEBAR_SOURCE_KEY)).toBe("builtin");
|
||||
expect(data.get(AI_SIDEBAR_ADAPTER_KEY)).toBe("claudeCode");
|
||||
expect(snapshots).toHaveLength(2);
|
||||
} finally {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
@@ -75,9 +75,15 @@ export const AI_SIDEBAR_LOCAL_THREADS_KEY = "edgeever.aiSidebar.localThreads";
|
||||
export const AI_SIDEBAR_SOURCE_KEY = "edgeever.aiSidebar.source";
|
||||
export const AI_SIDEBAR_ADAPTER_KEY = "edgeever.aiSidebar.adapterId";
|
||||
export const AI_SIDEBAR_ADAPTER_PATH_KEY = "edgeever.aiSidebar.adapterPath";
|
||||
export const AI_SIDEBAR_SELECTION_EVENT = "edgeever:ai-sidebar-selection";
|
||||
|
||||
export type AiSidebarSource = "builtin" | "local";
|
||||
|
||||
export const startsNewLocalAgentThread = (
|
||||
previous: { source: AiSidebarSource; adapterId: DesktopAcpAdapterId | null },
|
||||
next: { source: AiSidebarSource; adapterId: DesktopAcpAdapterId | null },
|
||||
) => next.source === "local" && (previous.source !== "local" || previous.adapterId !== next.adapterId);
|
||||
|
||||
const AI_SIDEBAR_ADAPTER_IDS = new Set<string>([
|
||||
"codex", "claudeCode", "antigravity", "openClaw", "hermesAgent", "grokBuild", "deepseekHarness", "piAgent", "workbuddyCn", "workbuddyIntl",
|
||||
]);
|
||||
@@ -111,6 +117,27 @@ export const readAiSidebarAdapter = () => aiSidebarAdapterFromStorage(
|
||||
readStorageItem(AI_SIDEBAR_ADAPTER_PATH_KEY),
|
||||
);
|
||||
|
||||
// Keep the settings page and sidebar on the same existing preference keys.
|
||||
export const selectAiSidebarAgent = (source: AiSidebarSource, adapterId?: DesktopAcpAdapterId) => {
|
||||
const storage = window.localStorage;
|
||||
const previousSource = storage.getItem(AI_SIDEBAR_SOURCE_KEY);
|
||||
const previousAdapter = storage.getItem(AI_SIDEBAR_ADAPTER_KEY);
|
||||
try {
|
||||
if (adapterId) storage.setItem(AI_SIDEBAR_ADAPTER_KEY, adapterId);
|
||||
storage.setItem(AI_SIDEBAR_SOURCE_KEY, source);
|
||||
} catch (error) {
|
||||
try {
|
||||
const preferences: Array<[string, string | null]> = [[AI_SIDEBAR_SOURCE_KEY, previousSource], [AI_SIDEBAR_ADAPTER_KEY, previousAdapter]];
|
||||
for (const [key, value] of preferences) {
|
||||
if (value === null) storage.removeItem(key);
|
||||
else storage.setItem(key, value);
|
||||
}
|
||||
} catch { /* Report the original preference-write failure. */ }
|
||||
throw error;
|
||||
}
|
||||
window.dispatchEvent(new Event(AI_SIDEBAR_SELECTION_EVENT));
|
||||
};
|
||||
|
||||
const bridge = () => (typeof window === "undefined" ? undefined : window.edgeeverDesktop);
|
||||
|
||||
export const desktopAcpAvailable = () => Boolean(bridge()?.listAcpAdapters);
|
||||
|
||||
@@ -1487,6 +1487,15 @@ export const enUS = {
|
||||
imageSaveFailed: "The image could not be saved locally. Download it now; it may be lost when you close this page.",
|
||||
},
|
||||
agentSource: {
|
||||
configure: "Configure models and agents",
|
||||
switchNoteAgentHint: "The selected agent will handle subsequent edits to this infographic.",
|
||||
optionUnavailable: "Unavailable",
|
||||
|
||||
switch: "Switch AI",
|
||||
newAgentThread: "Switching to another agent starts a new chat. Previous chats remain in history.",
|
||||
noModels: "Configure AI models in Settings.",
|
||||
switchUnavailable: "Agent unavailable. Check its connection in Settings.",
|
||||
|
||||
title: "Agent mode",
|
||||
description: "Use the built-in Agent, or connect to an Agent (ACP) running on this machine.",
|
||||
localDisabled: "Requires the desktop app.",
|
||||
|
||||
@@ -1487,6 +1487,15 @@ export const ja = {
|
||||
imageSaveFailed: "画像を端末に保存できませんでした。今すぐダウンロードしてください。ページを閉じると失われる可能性があります。",
|
||||
},
|
||||
agentSource: {
|
||||
configure: "モデルと Agent を設定",
|
||||
switchNoteAgentHint: "選択した Agent が、このインフォグラフィックの次の編集を処理します。",
|
||||
optionUnavailable: "利用不可",
|
||||
|
||||
switch: "AI を切り替え",
|
||||
newAgentThread: "外部 Agent への切り替えは新しい会話を開始します。以前の会話は履歴に残ります。",
|
||||
noModels: "設定で AI モデルを構成してください。",
|
||||
switchUnavailable: "Agent は利用できません。設定で接続を確認してください。",
|
||||
|
||||
title: "エージェントの動作モード",
|
||||
description: "内蔵 Agent を使用するか、このマシンで動作する Agent (ACP) に接続します。",
|
||||
localDisabled: "デスクトップアプリが必要です。",
|
||||
|
||||
@@ -1487,6 +1487,15 @@ export const pl = {
|
||||
imageSaveFailed: "Nie udało się zapisać obrazu lokalnie. Pobierz go teraz — może zostać utracony po zamknięciu tej strony.",
|
||||
},
|
||||
agentSource: {
|
||||
configure: "Konfiguruj modele i agentów",
|
||||
switchNoteAgentHint: "Wybrany agent obsłuży kolejne zmiany tej infografiki.",
|
||||
optionUnavailable: "Niedostępny",
|
||||
|
||||
switch: "Zmień AI",
|
||||
newAgentThread: "Zmiana na zewnętrznego agenta rozpoczyna nową rozmowę. Poprzednie rozmowy pozostają w historii.",
|
||||
noModels: "Skonfiguruj modele AI w ustawieniach.",
|
||||
switchUnavailable: "Agent jest niedostępny. Sprawdź połączenie w ustawieniach.",
|
||||
|
||||
title: "Tryb agenta",
|
||||
description: "Użyj wbudowanego agenta lub połącz się z agentem (ACP) działającym na tym komputerze.",
|
||||
localDisabled: "Wymaga aplikacji desktopowej.",
|
||||
|
||||
@@ -1487,6 +1487,15 @@ export const zhCN = {
|
||||
imageSaveFailed: "图片暂时无法保存到本机。请先下载图片,关闭页面后可能无法找回。",
|
||||
},
|
||||
agentSource: {
|
||||
configure: "配置模型与 Agent",
|
||||
switchNoteAgentHint: "切换后由所选 Agent 继续处理当前信息图。",
|
||||
optionUnavailable: "不可用",
|
||||
|
||||
switch: "切换 AI",
|
||||
newAgentThread: "切换外部 Agent 会开启新对话,原对话保留在历史中。",
|
||||
noModels: "请在设置中配置 AI 模型。",
|
||||
switchUnavailable: "Agent 暂不可用,请在设置中检查连接。",
|
||||
|
||||
title: "Agent 运行模式",
|
||||
description: "使用应用内置 Agent,或连接本机运行的 Agent (ACP)。",
|
||||
localDisabled: "仅支持桌面客户端。",
|
||||
|
||||
Reference in new issue
Block a user