mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
53d3e05bb4
* feat(ai): provider switcher chip in the Catty Agent chat input Catty Agent currently has no model chip in the chat input — modelPresets is empty for the catty agentId so `hasModelPicker` is false. The provider/model the chat ends up using comes from the global `activeProviderId` / `activeModelId`, which means switching providers requires opening Settings → AI → Providers. Closes the gap surfaced in the original feedback on #1101 (problem 2) and partially addresses #986 (per-context model switching) by going per-agent. State layer adds an `agentProviderMap` (`Record<agentId, providerId>`) alongside the existing `agentModelMap`. Both are written together when the user picks from the new dropdown; together they form a per-agent override that beats the global active provider/model when set. Cross- window sync mirrors the agentModelMap pattern. ChatInput grows a `providerSwitcher` prop carrying the enabled ProviderConfigs, the selected (providerId, modelId), and an onSelect callback. When supplied, the model chip switches from the Cpu glyph to the provider's ProviderIconBadge + `providerName · modelId`, and the popover renders a two-column layout — providers on the left (icon + name + default model caption), that provider's known model(s) on the right. ACP agents (Claude/Codex) are untouched because they plumb their provider through the CLI, not the Vercel AI SDK. AIChatSidePanel resolves `effectiveActiveProvider` / `effectiveActiveModelId` for the catty agent from the per-agent map, falling back to the global selection. handleSend now passes those through to sendToCattyAgent so a provider picked in one Catty session applies everywhere Catty runs. For v1, each provider's right column shows just its configured `defaultModel`. The two-level structure is in place to absorb richer listings (cached /models, manual additions) later without UI surgery — left a note in the popover code. Refs #1101, #986. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * polish(ai): flatten provider picker to a single list Two-level dropdown was theatre — each ProviderConfig exposes exactly one model (its `defaultModel`), so the right column ended up showing the same string already captioned on the left row. Picking a provider implicitly picks its model; one click is enough. Also drops the `p.enabled !== false` filter on the picker list. The user's expectation, confirmed by feedback on the first cut, is that the chat-input list mirrors what Settings → AI → Providers shows. The per-provider `enabled` toggle is an "active-ish" flag, not a visibility gate; hiding disabled providers in the picker silently made everything but the one currently active disappear, which is what the reviewer hit. Pares the popover down to a vertical list — provider icon + name + default-model caption (mono if set, italic "configure default model" hint if not) + a Check on the bound row. Removes the `hoveredProviderId` state along with the right-column rendering since it's no longer driving anything. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * polish(ai): tune provider icon sizes in the chat input Chip icon was sitting at sm (20px badge) and crowded the h-6 toolbar chip alongside the truncated label; popover rows used the same sm, so nothing differentiated the picker from the chip visually. Adds an xs size to ProviderIconBadge (16px badge, 10px glyph, 9px letter fallback) and uses it on the chip. Popover rows step up to md (32px badge, 16px glyph) and the row padding grows accordingly so the larger brand mark has room to breathe — the picker now reads like a list of choices, the chip reads like a status line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): keep per-agent override honest in model fallback + chip label Two codex P1/P2 findings on #1107: P1 — When Catty has a per-agent provider override but no per-agent model (agentModelMap['catty'] empty), cattyAgentModelId fell through to the global `activeModelId`. That id belongs to whichever provider was globally active, not the one Catty is now bound to, so e.g. overriding to DeepSeek without a defaultModel happily sent gpt-4o (global) to DeepSeek and produced a wrong-model error. The fallback is now: stored agent model → override provider's defaultModel → empty. Only the no-override path keeps the activeModelId tail; the no-provider send guard catches the empty case for everyone else. P2 — ChatInput's selectedSwitcherProvider used `?? providers[0]` so the chip always displayed *some* provider even when none was actually bound (selectedProviderId missing). The rest of the pipeline (send guard, agentProviderMap) treats that as "no provider", so the chip was lying. Dropped the fallback; when nothing is bound the chip now shows a generic Cpu glyph + "Select provider" label until the user picks one from the popover. Adds `ai.chat.selectProvider` in en / zh-CN / ru. Refs codex review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): purge per-agent binding when the bound provider is deleted Codex local review (round 2) flagged that a saved Catty model id could outlive its provider. The chain: - catty bound to provider A (DeepSeek): agentProviderMap['catty']='A', agentModelMap['catty']='deepseek-v4-flash' - user deletes provider A - cattyAgentProvider falls back to the global active provider (B, OpenAI) - cattyAgentModelId still returned the saved 'deepseek-v4-flash' - send dispatched the DeepSeek model id to OpenAI → wrong-model error Two layers of fix: 1. **Defensive resolution** (AIChatSidePanel) — cattyAgentModelId now looks at the override provider before trusting the stored model id. If the override is stale (provider deleted), the stored model id is treated as orphan and the resolution falls back to the global active selection just like cattyAgentProvider already does. 2. **Cleanup on remove** (useAIState.removeProvider) — when a provider is deleted, any agent whose agentProviderMap entry pointed at it gets both maps cleared (provider override + saved model). Same rationale: that model id is now meaningless against every other provider. Mirrors the existing activeProviderId cleanup. Adds a small agentProviderMapRef so removeProvider can snapshot which agents to clean up without relying on the captured-at-callback-create state (which would be stale). Refs codex local review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): include agentProviderMap in settings sync + block empty-model send Codex local review (round 3) found two issues: P2 — `agentProviderMap` was missing from the cross-device sync surface. Added it to the sync key list, the build path, the apply path, and the SyncPayload type alongside the existing `agentModelMap`. Sync tests in `syncPayload.test.ts` now cover the new field on both the build and apply sides. P2 — Provider rows with no `defaultModel` were still clickable in the chat-input picker, so selecting one would save a binding with empty model id; the send path then dispatched an empty model name and the SDK would surface a vague backend error. Two changes: 1. ChatInput disables the row (\`disabled\`, \`aria-disabled\`, dimmed styling, tooltip pointing the user to Settings) when the provider has no \`defaultModel\`. Click is suppressed. 2. AIChatSidePanel \`handleSend\` adds a model-required guard mirroring the existing no-provider guard — surfaces \`ai.chat.noProviderModel\` as an assistant message. Catches stale bindings (e.g. user edited the provider's \`defaultModel\` to empty after binding) before they reach the SDK. Refs codex local review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): trim whitespace-only model ids + reconcile orphan bindings on sync Codex local review (round 4) found two more leaks: P2 — Whitespace-only `defaultModel` passed the picker's `disabled` gate (which trims) but every other layer (cattyAgentModelId resolution, the send-guard `!sendActiveModelId` check, the SDK call) compared the raw string. A provider whose default model was " " could still reach the SDK and surface a vague backend error. Normalized: trim at the resolution boundary (catty model fallback chain) and trim before the send-guard's existence check. P2 — Sync apply did not reconcile per-agent bindings against the incoming provider set. A payload could change `providers` without shipping a fresh `agentProviderMap`, leaving local overrides pointing to provider ids the synced set no longer includes — the same ghost- binding bug that `removeProvider` already handles for explicit user deletes. Added `pruneOrphanPerAgentBindings()` that runs after every AI settings apply: it walks `agentProviderMap`, drops any entry whose provider id isn't in the current `providers` list, and clears the saved model id for those agents alongside it (mirroring removeProvider cleanup). A new test in `syncPayload.test.ts` exercises the ghost- binding case end-to-end. Refs codex local review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): notify open AI state of cross-device sync apply in the same window Codex local review (round 4) flagged that `applySyncPayload` writes straight to localStorage but `useAIState` only re-reads on browser `storage` events. Those events only fire in *other* windows — the window doing the apply (the one with the active chat panel) keeps showing pre-sync providers and per-agent bindings until reload. Concrete leak: cloud sync swaps in a new provider set, pruning runs, localStorage is correct, but the open Catty chip still references the ghost provider binding until the user reloads. Extracts the existing `AI_STATE_CHANGED_EVENT` constant + `emitAIStateChanged` helper out of `useAIState` and into a new `application/state/aiStateEvents.ts` so non-React call sites (sync apply, future IPC handlers) can fire it without pulling in the hook. After AI settings apply, `syncPayload.ts` walks every AI key it touched (including the agentProviderMap / agentModelMap that the reconcile step may have mutated even when the payload didn't ship them) and emits a same-window nudge. `useAIState`'s existing local listener routes unknown keys back through its storage handler, so each affected piece of React state rehydrates from localStorage. Adds a regression test in `syncPayload.test.ts` that asserts the expected `netcatty:ai-state-changed` keys are dispatched for the providers + per-agent map keys. Refs codex local review on #1107. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 lines
823 B
TypeScript
21 lines
823 B
TypeScript
/**
|
|
* Same-window AI-state-changed event plumbing.
|
|
*
|
|
* `localStorage` writes only emit `storage` events in *other* windows; the
|
|
* window doing the write never gets notified. That's a problem for code
|
|
* that mutates AI storage outside of `useAIState`'s setters (e.g. sync
|
|
* apply): without a manual nudge, mounted components keep showing stale
|
|
* AI state until reload.
|
|
*
|
|
* Both the dispatcher and `useAIState`'s listener live here so non-React
|
|
* call sites (sync, IPC handlers, etc.) can fire the event without
|
|
* pulling in the hook.
|
|
*/
|
|
|
|
export const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
|
|
|
export function emitAIStateChanged(key: string): void {
|
|
if (typeof window === 'undefined') return;
|
|
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
|
}
|