diff --git a/apps/desktop/electron/main/ipc/agent-ipc.ts b/apps/desktop/electron/main/ipc/agent-ipc.ts index 5a3fd3d182..d7c3f9a926 100644 --- a/apps/desktop/electron/main/ipc/agent-ipc.ts +++ b/apps/desktop/electron/main/ipc/agent-ipc.ts @@ -1,4 +1,4 @@ -import { IPC, ErrorCodes, isGlobalPermissionMode, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest } from "@pi-desktop/shared"; +import { IPC, ErrorCodes, isGlobalPermissionMode, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest, type ThinkingLevel } from "@pi-desktop/shared"; import type { FinishTurn } from "../runtime/plans"; import { expandSlashInvocation, enhancePromptDraft, summarizeSessionTitle, visionFromModelConfig, type ComposerTemplate, type RuntimeProviderConfig } from "@pi-desktop/agent-runtime"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; @@ -13,6 +13,7 @@ import type { Logger } from "../logger"; import type { PersistenceOutbox } from "../persistence-outbox"; import type { ComposerCommandService } from "./composer-ipc"; import type { IpcRegistrar } from "./types"; +import { withPromptEnhancementTimeout } from "../prompt-enhancement-timeout"; export type AgentIpcDependencies = { registrar: IpcRegistrar; @@ -123,29 +124,72 @@ export function registerAgentIpc({ } const settings = await host.call("settings.get"); const launchSessionId = sessionId || `prompt-enhancement:${crypto.randomUUID()}`; - const launch = await resolveAgentRuntimeLaunch( - launchSessionId, - session ?? {}, - settings, - { + // A pinned enhancement model is a preference, not a hard requirement: a + // pin whose provider was disabled, whose account was signed out, or whose + // binding no longer exists must not take the action down. Try the pin, + // fall back to the Composer's current model, and record why (ADR 0121). + const pinnedProviderId = + typeof settings?.promptEnhancementProviderId === "string" + ? settings.promptEnhancementProviderId.trim() + : ""; + const pinnedModelId = + typeof settings?.promptEnhancementModelId === "string" + ? settings.promptEnhancementModelId.trim() + : ""; + const composerProviderId = + typeof req.providerId === "string" ? req.providerId.trim() : undefined; + const composerModelId = + typeof req.modelId === "string" ? req.modelId.trim() : undefined; + // The enhancement carries its own reasoning level and never follows the + // conversation's: an unset value means "off", because a rewrite rarely + // benefits from reasoning and reasoning is the slow path. + const enhancementThinkingLevel = + typeof settings?.promptEnhancementThinkingLevel === "string" + ? settings.promptEnhancementThinkingLevel.trim() + : ""; + const launchFor = (providerId?: string, modelId?: string) => + resolveAgentRuntimeLaunch(launchSessionId, session ?? {}, settings, { mode: "agent", - providerId: - typeof req.providerId === "string" ? req.providerId.trim() : undefined, - modelId: typeof req.modelId === "string" ? req.modelId.trim() : undefined, - thinkingLevel: req.thinkingLevel, - }, - ); + providerId, + modelId, + thinkingLevel: (enhancementThinkingLevel || "off") as ThinkingLevel, + }); + let launch: Awaited>; + if (pinnedProviderId) { + try { + launch = await launchFor(pinnedProviderId, pinnedModelId || undefined); + } catch (error) { + logger.app("session", "warn", "prompt enhancement model unavailable", { + data: { + pinnedProviderId, + pinnedModelId: pinnedModelId || undefined, + error: error instanceof Error ? error.message : String(error), + }, + }); + launch = await launchFor(composerProviderId, composerModelId); + } + } else { + launch = await launchFor(composerProviderId, composerModelId); + } const runtimeProvider = { ...launch.sidecarParams.provider, ...(launch.sidecarParams.provider.authKind === OAUTH_AUTH_KIND ? { resolveAuth: () => vendorOAuth.resolveAuth(launch.providerId) } : {}), } as RuntimeProviderConfig; - const enhancedDraft = await enhancePromptDraft( - runtimeProvider, - draft, - launch.sidecarParams.thinkingLevel, - { sessionId: launchSessionId }, + // A pin, a slow gateway, or a stalled connection would otherwise hold this + // promise open indefinitely. The transport only consults the signal between + // provider retries, so aborting is best-effort cancellation; racing the + // promise is what actually guarantees the caller is released on time. + const enhancedDraft = await withPromptEnhancementTimeout( + enhancePromptDraft(runtimeProvider, draft, launch.sidecarParams.thinkingLevel, { + sessionId: launchSessionId, + customTemplate: settings?.promptEnhancementCustomTemplate === true, + userTemplate: + typeof settings?.promptEnhancementUserTemplate === "string" + ? settings.promptEnhancementUserTemplate + : undefined, + }), ); logger.app("session", "info", "prompt enhanced", { sessionId: sessionId || undefined, diff --git a/apps/desktop/electron/main/prompt-enhancement-timeout.ts b/apps/desktop/electron/main/prompt-enhancement-timeout.ts new file mode 100644 index 0000000000..54155d1454 --- /dev/null +++ b/apps/desktop/electron/main/prompt-enhancement-timeout.ts @@ -0,0 +1,57 @@ +/** + * Bound one prompt-enhancement request. + * + * The transport only consults its abort signal between provider retries, so + * aborting is best-effort cancellation. Racing the promise is what actually + * guarantees the caller is released: without it a stalled connection or a slow + * gateway holds the renderer's promise open indefinitely, and the provider + * retry budget alone can already spend about a minute before giving up. + * + * Kept in its own module (no Electron or pi-ai imports) so the behavior is + * directly testable. + */ + +/** Hard ceiling for one enhancement request. */ +export const PROMPT_ENHANCEMENT_TIMEOUT_MS = 60_000; + +/** Classified timeout failure the renderer renders as a dismissible error. */ +export type PromptEnhancementTimeoutError = Error & { + errorCode: "TIMEOUT"; +}; + +export function promptEnhancementTimeoutError( + timeoutMs: number, +): PromptEnhancementTimeoutError { + return Object.assign( + new Error( + `Prompt enhancement timed out after ${Math.round(timeoutMs / 1000)}s. Try again, or set the enhancement model to follow the session in Settings.`, + ), + { errorCode: "TIMEOUT" as const }, + ); +} + +/** + * Resolve with `work`, or reject with a `TIMEOUT` error after `timeoutMs`. + * + * A failure from `work` itself passes through unchanged; only an unanswered + * request becomes a timeout. The rejection is never retried on another model: + * the user chose this one, and a hidden second attempt would double the wait. + */ +export function withPromptEnhancementTimeout( + work: Promise, + timeoutMs: number = PROMPT_ENHANCEMENT_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(promptEnhancementTimeoutError(timeoutMs)), timeoutMs); + work.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} diff --git a/apps/desktop/src/components/settings/EnhancementModelCard.tsx b/apps/desktop/src/components/settings/EnhancementModelCard.tsx new file mode 100644 index 0000000000..dc951ca993 --- /dev/null +++ b/apps/desktop/src/components/settings/EnhancementModelCard.tsx @@ -0,0 +1,304 @@ +/** + * Enhancement prompt model and reasoning (ADR 0121). + * + * Which model rewrites the Composer draft, and with how much reasoning, are + * model decisions, so they live on this page next to the default model. They get + * their own card rather than joining the Defaults card: that card's rows pair a + * short value with one control, and the enhancement model needs a title, the + * current value, and a picker — the same shape as the default-model row, which + * has a card of its own for exactly that reason. + * + * The picker reuses the default-model anchored menu so the page offers one kind + * of model picker, and the reasoning row reuses the shared settings menu select. + */ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + THINKING_LEVELS, + modelIdsMatch, + type AppSettings, + type ThinkingLevel, +} from "@pi-desktop/shared"; +import { + thinkingLevelForProvider, + thinkingProviderForModel, +} from "../../features/chat/composer/model"; +import { useAppStore } from "../../stores/app-store"; +import { api } from "../../lib/api"; +import { Button, Input, cx } from "../ui"; +import { IconCheck, IconChevronDown, IconSearch } from "../icons"; +import { AnchoredMenu } from "./AnchoredMenu"; +import { SettingsMenuSelect } from "./SettingsMenuSelect"; +import { SettingsRow } from "../../features/settings/primitives"; +import { defaultModelOptions } from "./default-model"; +import { + groupSubagentModelChoices, + subagentModelChoices, + subagentModelOrphanPin, + subagentModelSelectValue, +} from "./subagent-models"; + +export function EnhancementModelCard() { + const { t } = useTranslation(); + const providers = useAppStore((state) => state.providers); + const providerModels = useAppStore((state) => state.providerModels); + const settings = useAppStore((state) => state.settings); + const showToast = useAppStore((state) => state.showToast); + const [picking, setPicking] = useState(false); + const [query, setQuery] = useState(""); + + const choices = useMemo(() => subagentModelChoices(providers), [providers]); + const groups = useMemo(() => groupSubagentModelChoices(choices), [choices]); + const pinnedValue = useMemo(() => { + if (!settings?.promptEnhancementProviderId || !settings?.promptEnhancementModelId) return ""; + return subagentModelSelectValue( + `${settings.promptEnhancementProviderId}/${settings.promptEnhancementModelId}`, + choices, + ); + }, [choices, settings?.promptEnhancementProviderId, settings?.promptEnhancementModelId]); + const orphanPin = useMemo( + () => subagentModelOrphanPin(pinnedValue, choices), + [pinnedValue, choices], + ); + // The menu lists runnable providers, same set the Composer would offer. + const options = useMemo( + () => defaultModelOptions(providers.filter((provider) => provider.enabled)), + [providers], + ); + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return options; + return options.filter(({ provider, modelId }) => + `${provider.name} ${modelId}`.toLowerCase().includes(needle), + ); + }, [options, query]); + + const pinnedProviderId = settings?.promptEnhancementProviderId ?? ""; + const pinnedModelId = settings?.promptEnhancementModelId ?? ""; + const pinnedProvider = + providers.find((provider) => provider.id === pinnedProviderId) ?? null; + /** + * The repository resolves a model's real reasoning ladder from its binding's + * `thinkingLevels`, then the live catalog, then the provider default. Reusing + * it keeps this row honest: a model without reasoning offers only `off` + * instead of the full canonical list, which is what this row showed before. + */ + const reasoningProvider = useMemo(() => { + if (!pinnedProviderId || !pinnedModelId) return null; + return thinkingProviderForModel( + pinnedProvider, + pinnedModelId, + providerModels[pinnedProviderId], + ); + }, [pinnedProvider, providerModels, pinnedProviderId, pinnedModelId]); + const reasoningLevels = useMemo( + () => + reasoningProvider?.supportsReasoning + ? THINKING_LEVELS.filter((level) => + (reasoningProvider.supportedThinkingLevels ?? []).includes(level), + ) + : [], + [reasoningProvider], + ); + // A pinned model defines the ladder; with no pin the request follows the + // session's model, whose ladder is not knowable here, so offer the full list. + const levelOptions = reasoningProvider ? reasoningLevels : [...THINKING_LEVELS]; + const storedReasoning = settings?.promptEnhancementThinkingLevel ?? "off"; + const reasoning = useMemo(() => { + if (!reasoningProvider) return storedReasoning; + return thinkingLevelForProvider(reasoningProvider, storedReasoning); + }, [reasoningProvider, storedReasoning]); + + // Every hook runs before this: `settings` arrives after the first bootstrap, + // so an early return above them would change the hook count between renders. + if (!settings) return null; + + const save = async (patch: Partial) => { + const next = { ...settings, ...patch }; + try { + await api.setSettings(next); + useAppStore.setState({ settings: next }); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), { + variant: "error", + }); + } + }; + + const pickModel = async (providerId: string, modelId: string) => { + // Switching model can change the reasoning ladder (a model without + // reasoning has none), so re-clamp the stored level onto the new model. + const nextProvider = providerId + ? thinkingProviderForModel( + providers.find((provider) => provider.id === providerId) ?? null, + modelId, + providerModels[providerId], + ) + : null; + const stored = settings.promptEnhancementThinkingLevel ?? "off"; + await save({ + promptEnhancementProviderId: providerId, + promptEnhancementModelId: modelId, + promptEnhancementThinkingLevel: nextProvider + ? thinkingLevelForProvider(nextProvider, stored) + : stored, + }); + setPicking(false); + }; + + return ( +
+
+

{t("settings.promptEnhancementModelTitle")}

+
+
+ + {pinnedProvider.name} + + · + + + {settings.promptEnhancementModelId} + + + ) : ( + + {t("settings.promptEnhancementModelFollow")} + + ) + } + > + setPicking(false)} + menuClassName="model-default-menu" + label={t("settings.promptEnhancementModel")} + align="end" + trigger={(ref) => ( + + )} + > +
+ + setQuery(event.target.value)} + placeholder={t("settings.defaultModelSearch")} + aria-label={t("settings.defaultModelSearch")} + autoFocus + /> +
+
+ {visible.length === 0 ? ( +
{t("settings.noModelMatches")}
+ ) : null} +
    +
  • + +
  • + {visible.map(({ provider, modelId }, index) => { + const isCurrent = + settings.promptEnhancementProviderId === provider.id && + modelIdsMatch(settings.promptEnhancementModelId ?? "", modelId); + const previous = visible[index - 1]; + const startsGroup = !previous || previous.provider.id !== provider.id; + return ( +
  • + {startsGroup ? ( +
    0 && "has-divider", + )} + > + {provider.name} +
    + ) : null} + +
  • + ); + })} +
+
+
+
+ + + ({ + id: level, + label: level === "off" ? t("settings.promptEnhancementThinkingOff") : level, + }))} + onChange={(id) => + void save({ + // Clamp through the same resolver the row displays, so the value + // stored is always one this model can run. + promptEnhancementThinkingLevel: reasoningProvider + ? thinkingLevelForProvider(reasoningProvider, id as ThinkingLevel) + : (id as ThinkingLevel), + }) + } + /> + +
+
+ ); +} diff --git a/apps/desktop/src/components/settings/ModelConfigPage.tsx b/apps/desktop/src/components/settings/ModelConfigPage.tsx index 30488287c8..0303b77acc 100644 --- a/apps/desktop/src/components/settings/ModelConfigPage.tsx +++ b/apps/desktop/src/components/settings/ModelConfigPage.tsx @@ -38,6 +38,7 @@ import { } from "./default-model"; import { copyProviderConfiguration, type ProviderCopyDraft } from "./provider-copy"; import { ProviderSetupDialog } from "./ProviderSetupDialog"; +import { EnhancementModelCard } from "./EnhancementModelCard"; import { VendorAccountsSection } from "./VendorAccountsSection"; const DELETE_CONFIRM_MS = 3000; @@ -126,6 +127,7 @@ export function ModelConfigPage() { ); }, [defaultModelOptionsList, defaultModelQuery]); + if (!settings) return null; const defaultProvider = @@ -134,6 +136,7 @@ export function ModelConfigPage() { setupFor ? providers.find((provider) => provider.id === setupFor) ?? null : null; const defaultProviderReady = defaultProvider !== null && providerReady(defaultProvider); + const setDefaultModel = async (provider: ProviderPublic, modelId: string) => { setBusyId(provider.id); try { @@ -415,6 +418,8 @@ export function ModelConfigPage() { + +
diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index 5b85d54c79..1d1452eaa5 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -54,6 +54,7 @@ import { ImportSection, UpdatesRow, } from "./agent-sections"; +import { PromptEnhancementCard } from "./prompt-enhancement-card"; import { VoiceSettingsCard } from "./voice-settings"; import { CloseBehaviorSection, DeveloperSection } from "./developer-sections"; import { PluginSettingsDestination } from "../../components/settings/PluginSettingsDestination"; @@ -427,6 +428,11 @@ export function SettingsPage() { saveSettings={saveSettings} /> + +
)} diff --git a/apps/desktop/src/features/settings/prompt-enhancement-card.tsx b/apps/desktop/src/features/settings/prompt-enhancement-card.tsx new file mode 100644 index 0000000000..0df91eef71 --- /dev/null +++ b/apps/desktop/src/features/settings/prompt-enhancement-card.tsx @@ -0,0 +1,285 @@ +/** + * Prompt-enhancement settings (ADR 0121). + * + * This card owns the prompt itself: a switch that chooses between the built-in + * user template and a saved one, and the settings icon button that opens the + * template editor. Which model runs the rewrite, and with how much reasoning, + * is a model decision and lives on the Model configuration page. + * + * What is deliberately not editable: the system prompt. It carries the rewrite + * contract the feature is verified against (proper-noun preservation, language + * following without meta notes, the output contract), so it stays a built-in + * default and host-core drops any stored override. + * + * The template field shows the built-in default text when no override is saved, + * so the editor opens on the value in force, and "restore default" is + * self-explanatory. Editing the field back to the exact default text clears the + * override rather than storing a frozen copy, so a later product improvement to + * the default still reaches users who never customized it. + */ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { AppSettings } from "@pi-desktop/shared"; +import { + PROMPT_ENHANCEMENT_DEFAULT_USER_TEMPLATE, + PROMPT_ENHANCEMENT_DRAFT_VARIABLE, + isValidPromptEnhancementUserTemplate, +} from "@pi-desktop/shared"; +import { useAppStore } from "../../stores/app-store"; +import { Button, Field, TooltipButton, cx, portalOverlay } from "../../components/ui"; +import { IconPencil, IconX } from "../../components/icons"; +import { SettingsCard, SettingsRow } from "./primitives"; + +export function PromptEnhancementCard({ + settings, + saveSettings, +}: { + settings: AppSettings; + saveSettings: (patch: Partial) => Promise; +}) { + const { t } = useTranslation(); + const [editorOpen, setEditorOpen] = useState(false); + const customTemplate = settings.promptEnhancementCustomTemplate === true; + // A saved, usable template is what makes the switch meaningful. + const hasCustomTemplate = isValidPromptEnhancementUserTemplate( + settings.promptEnhancementUserTemplate, + ); + + return ( + + + {/* + The switch selects between a saved custom template and the built-in + one, so it means nothing until a template has been saved. It is + disabled rather than hidden: the user can see that the choice exists + and that editing is what unlocks it. + */} + + {/* + The subagent list's edit affordance: a tooltipped icon button, so the + row keeps one control cluster instead of three competing labels. + */} + setEditorOpen(true)} + > + + + + + {editorOpen ? ( + setEditorOpen(false)} + /> + ) : null} + + ); +} + +/** + * The editor itself. Drafts are local until Save, so closing the sheet abandons + * the edit — the same contract as the subagent editor. + */ +function PromptEnhancementEditorSheet({ + settings, + saveSettings, + onClose, +}: { + settings: AppSettings; + saveSettings: (patch: Partial) => Promise; + onClose: () => void; +}) { + const { t } = useTranslation(); + const savedTemplate = settings.promptEnhancementUserTemplate ?? ""; + const [templateDraft, setTemplateDraft] = useState( + savedTemplate || PROMPT_ENHANCEMENT_DEFAULT_USER_TEMPLATE, + ); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(false); + const templateRef = useRef(null); + + const templateMissingVariable = + templateDraft.trim().length > 0 && + !isValidPromptEnhancementUserTemplate(templateDraft); + const dirty = + templateDraft !== (savedTemplate || PROMPT_ENHANCEMENT_DEFAULT_USER_TEMPLATE); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + // Escape closes only when nothing is in flight, so a save cannot be + // abandoned halfway through. + if (event.key === "Escape" && !saving) onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [saving, onClose]); + + const insertDraftVariable = () => { + const element = templateRef.current; + if (!element) { + setTemplateDraft((current) => `${current}${PROMPT_ENHANCEMENT_DRAFT_VARIABLE}`); + return; + } + const start = element.selectionStart ?? templateDraft.length; + const end = element.selectionEnd ?? start; + setTemplateDraft( + `${templateDraft.slice(0, start)}${PROMPT_ENHANCEMENT_DRAFT_VARIABLE}${templateDraft.slice(end)}`, + ); + const caret = start + PROMPT_ENHANCEMENT_DRAFT_VARIABLE.length; + requestAnimationFrame(() => { + element.focus(); + element.setSelectionRange(caret, caret); + }); + }; + + const restoreTemplateDefault = () => { + setTemplateDraft(PROMPT_ENHANCEMENT_DEFAULT_USER_TEMPLATE); + }; + + const save = async () => { + if (templateMissingVariable || saving) return; + setSaving(true); + setSaveError(false); + try { + // The default text is never persisted, so a later change to the default + // still reaches a user who left the field at its default value. + const savedTemplateValue = + templateDraft === PROMPT_ENHANCEMENT_DEFAULT_USER_TEMPLATE + ? "" + : templateDraft; + const templateChanged = savedTemplateValue !== savedTemplate; + await saveSettings({ + promptEnhancementUserTemplate: savedTemplateValue, + // Saving a template switches it on, because the user just wrote one. + promptEnhancementCustomTemplate: templateChanged + ? Boolean(savedTemplateValue.trim()) + : settings.promptEnhancementCustomTemplate === true, + }); + onClose(); + } catch { + setSaveError(true); + } finally { + setSaving(false); + } + }; + + return portalOverlay( +
{ + if (event.target === event.currentTarget && !saving) onClose(); + }} + > +
+
+
+

+ {t("settings.promptEnhancementTitle")} +

+
{t("settings.promptEnhancementDesc")}
+
+ + + +
+ +
+ + {/* A plain textarea: the shared Textarea wrapper does not forward a + ref, and the insert action needs one to place the caret. */} +