diff --git a/src/components/providers/forms/ClaudeStackModelsField.tsx b/src/components/providers/forms/ClaudeStackModelsField.tsx index f183592e4..5d1a4b368 100644 --- a/src/components/providers/forms/ClaudeStackModelsField.tsx +++ b/src/components/providers/forms/ClaudeStackModelsField.tsx @@ -170,7 +170,7 @@ export function ClaudeStackModelsField({

{t("providerForm.stackModelsHint", { defaultValue: - "这些模型会出现在 Claude Code 的 /model 里,选中后请求直达这家。第一个(★)是这家的默认模型:这家被设为默认时,Claude Code 启动和后台任务都用它。修改后需要重启 Claude Code。", + "这些模型会出现在 Claude Code 的 /model 里,选中后请求直达这家。第一个(★)是这家的默认模型:这家被设为默认时,Claude Code 启动和后台任务都用它。", })}

diff --git a/src/components/providers/mode/ModeDialog.tsx b/src/components/providers/mode/ModeDialog.tsx index 096460e29..a4f50c921 100644 --- a/src/components/providers/mode/ModeDialog.tsx +++ b/src/components/providers/mode/ModeDialog.tsx @@ -134,9 +134,10 @@ function EnterBody({ }) : t("mode.dialog.stackNoteEmpty"), t("mode.dialog.stackNoteNoFailover"), - app === "codex" - ? t("mode.dialog.stackNoteRestartCodex", { app: appName }) - : t("mode.dialog.stackNoteRestart", { app: appName }), + // Claude Code 运行中就会读到改过的 settings.json,不用重启。 + ...(app === "codex" + ? [t("mode.dialog.stackNoteRestartCodex", { app: appName })] + : []), ]; if (active === "stack" && target === "route") { notes.push(t("mode.dialog.stackToRouteNote")); diff --git a/src/components/providers/mode/SwitchModePanel.tsx b/src/components/providers/mode/SwitchModePanel.tsx index ff7ba3ccb..af921d752 100644 --- a/src/components/providers/mode/SwitchModePanel.tsx +++ b/src/components/providers/mode/SwitchModePanel.tsx @@ -26,7 +26,6 @@ import { useSetAutoFailoverEnabled, } from "@/lib/query/failover"; import { useModeActions } from "@/hooks/useModeActions"; -import { useStackModelsChangedHint } from "@/hooks/useStackModelsChangedHint"; import { getRoutingReason } from "@/utils/routingReason"; import { extractErrorMessage } from "@/utils/errorUtils"; import { Button } from "@/components/ui/button"; @@ -122,17 +121,9 @@ export function SwitchModePanel({ const setFailover = useSetAutoFailoverEnabled(); const addToQueue = useAddToFailoverQueue(); const removeFromQueue = useRemoveFromFailoverQueue(); - const { data: stack, dataUpdatedAt: stackUpdatedAt } = useProxyStack( - app, - isStackAppId(app), - ); + const { data: stack } = useProxyStack(app, isStackAppId(app)); const setStackMember = useSetProxyStackMember(); const adoptCatalog = useAdoptCodexStackCatalog(); - const skipNextStackHint = useStackModelsChangedHint( - app, - active === "stack" ? stack : undefined, - stackUpdatedAt, - ); const modeActions = useModeActions(app); const [dialog, setDialog] = useState(null); @@ -258,7 +249,6 @@ export function SwitchModePanel({ queueMove: (provider: Provider, delta: -1 | 1) => void queueMove(provider, delta), stackAdd: (provider: Provider) => { - skipNextStackHint(); setStackMember.mutate({ appType: app, providerId: provider.id, @@ -266,7 +256,6 @@ export function SwitchModePanel({ }); }, stackRemove: (provider: Provider) => { - skipNextStackHint(); setStackMember.mutate({ appType: app, providerId: provider.id, diff --git a/src/hooks/useModeActions.ts b/src/hooks/useModeActions.ts index ceeb754be..c146acc8f 100644 --- a/src/hooks/useModeActions.ts +++ b/src/hooks/useModeActions.ts @@ -62,7 +62,12 @@ export function useModeActions(app: AppId) { app: appName, provider: routeName ?? "", }) - : t("mode.toast.enteredStack", { app: appName }), + : t( + app === "codex" + ? "mode.toast.enteredStack" + : "mode.toast.enteredStackLive", + { app: appName }, + ), { closeButton: true }, ); } finally { diff --git a/src/hooks/useStackModelsChangedHint.ts b/src/hooks/useStackModelsChangedHint.ts deleted file mode 100644 index 97e90c7e2..000000000 --- a/src/hooks/useStackModelsChangedHint.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { toast } from "@/lib/toast"; -import { getAppLabel } from "@/config/appConfig"; -import type { ProxyStack } from "@/types/proxy"; - -/** 发布的 Stack 模型 id(去重、排序:换顺序不影响能选哪些模型)。 */ -function publishedSignature(stack: ProxyStack): string { - const ids = new Set(stack.members.flatMap((member) => member.modelIds)); - return [...ids].sort().join("\n"); -} - -/** - * Claude Code 只在启动时向代理取一次模型列表:Stack 模式下发布的模型变了(编辑、删除名单里的 - * 供应商,或者别处的改动),提示重启 Claude Code。加入、移出名单时保存的提示已经说过要重启, - * 调用方在发起之前调返回的函数,跳过紧接着的那一次重查。Codex 另有按进程判断的横幅 - * (`CodexStaleClientsNotice`),这里不管。 - * - * 按 `updatedAt` 比较:内容没变的重查也会消费「跳过」,不会留着吞掉之后真正的变化。 - */ -export function useStackModelsChangedHint( - appId: string, - stack: ProxyStack | undefined, - updatedAt: number, -): () => void { - const { t } = useTranslation(); - const baseline = useRef(null); - const skipNext = useRef(false); - const signature = - appId === "claude" && stack?.active ? publishedSignature(stack) : null; - - useEffect(() => { - if (signature === null) { - baseline.current = null; - skipNext.current = false; - return; - } - const previous = baseline.current; - baseline.current = signature; - if (skipNext.current) { - skipNext.current = false; - return; - } - if (previous !== null && previous !== signature) { - toast.info( - t("provider.stackModelsChanged", { client: getAppLabel(appId) }), - { closeButton: true }, - ); - } - // `t` 不在依赖里:切换语言不是一次重查,不能消费「跳过」。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [appId, signature, updatedAt]); - - return useCallback(() => { - skipNext.current = true; - }, []); -} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index af8c0da06..6ad4cb0d0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -110,7 +110,7 @@ "stackBadgeHint": "Pick in {{client}}'s model picker: {{models}}", "stackBadgeNoModels": "This provider has no models configured, so nothing shows up in the model picker", "stackSaved": "Saved. Restart {{client}} to apply", - "stackModelsChanged": "Aggregated models changed. Restart {{client}} to apply", + "stackSavedLive": "Saved. Takes effect in {{client}} right away", "stackReselectHint": "Whenever CC Switch rewrites the client config, pick the aggregated model again in the model picker", "stackPartial": "Partly written; it will be finished on the next change or when CC Switch restarts", "stackFailed": "Failed to save aggregated models: {{error}}", @@ -387,7 +387,7 @@ "enableFeature": "Show Routing Toggle on Main Page", "enableFeatureDescription": "When enabled, the routing and failover toggles will appear at the top of the main page", "enableStackMode": "Show the Aggregation mode switch on the main page", - "enableStackModeDescription": "When on, the main-page switch for Claude Code and Codex becomes the Aggregation mode: add several providers like in OpenCode, and all their models appear in the client's model list (after restarting the client). No failover. This and the local routing switch are mutually exclusive; switching sends Claude Code and Codex back to direct first", + "enableStackModeDescription": "When on, the main-page switch for Claude Code and Codex becomes the Aggregation mode: add several providers like in OpenCode, and all their models appear in the client's model list (Codex needs a restart first). No failover. This and the local routing switch are mutually exclusive; switching sends Claude Code and Codex back to direct first", "enableFailoverToggle": "Show Failover Toggle on Main Page", "enableFailoverToggleDescription": "When enabled, the failover toggle will appear independently at the top of the main page", "running": "Running", @@ -1548,7 +1548,7 @@ "modelMappingHint": "Display names only affect the /model menu; 1M is only a Claude Code context-capability declaration.", "stackModelsLabel": "Models", "addStackModel": "Add manually", - "stackModelsHint": "These models appear in Claude Code's /model menu; picking one sends requests straight to this provider. The first one (★) is this provider's default model: while this provider is the default, Claude Code uses it at startup and for background tasks. Restart Claude Code after changes.", + "stackModelsHint": "These models appear in Claude Code's /model menu; picking one sends requests straight to this provider. The first one (★) is this provider's default model: while this provider is the default, Claude Code uses it at startup and for background tasks.", "stackModelsEmpty": "No models configured: this provider won't appear in the model picker once added to the aggregation", "stackModelPlaceholder": "e.g. deepseek-v4-pro", "stackModelDefault": "Default model", @@ -3471,10 +3471,6 @@ "stackMode": { "enabled": "{{app}} is now in Aggregation mode", "exitedToDirect": "{{apps}} switched back to direct", - "tooltip": { - "active": "{{appLabel}} Aggregation mode is on: models of the added providers appear in the model list after restarting the client", - "inactive": "Enter Aggregation mode: add several providers and all their models appear in the {{appLabel}} model list" - }, "ariaLabel": "{{appLabel}} Aggregation mode", "failoverUnavailable": "{{appLabel}} is in Aggregation mode, which has no failover. The queue and settings are kept and apply again in routing mode", "codexStale": { @@ -3972,6 +3968,7 @@ "toast": { "enteredRoute": "{{app}} is now routing → {{provider}}.", "enteredStack": "{{app}} switched to Aggregation. Restart {{app}} to see the aggregated models in /model.", + "enteredStackLive": "{{app}} switched to Aggregation. The aggregated models are now in /model.", "exited": "Back to direct: {{app}} now connects to {{provider}} directly.", "defaultRemembered": "{{provider}} will be the default provider once you switch to Aggregation.", "restartHint": " Restart {{app}} to apply.", @@ -3994,7 +3991,6 @@ "stackNoteMembers": "{{names}} add {{count}} models, shown in /model with the ccs- prefix.", "stackNoteEmpty": "No other providers added yet, so only the default is available. You can add more on the Aggregation tab later.", "stackNoteNoFailover": "Aggregation has no failover. The queue and its settings are kept and come back with routing.", - "stackNoteRestart": "Restart {{app}} to see the aggregated models.", "stackNoteRestartCodex": "Restart {{app}} to see the aggregated models; quit the desktop app completely and reopen it.", "stackToRouteNote": "Aggregated models are withdrawn from /model (the list is kept); a selected aggregated model must be changed back to a regular one.", "confirmRoute": "Start routing", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b798c3058..8812d07af 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -110,7 +110,7 @@ "stackBadgeHint": "{{client}} のモデル選択で選べます:{{models}}", "stackBadgeNoModels": "モデルが設定されていないため、モデル選択には表示されません", "stackSaved": "保存しました。{{client}} を再起動すると反映されます", - "stackModelsChanged": "集約したモデルが変わりました。{{client}} を再起動すると反映されます", + "stackSavedLive": "保存しました。{{client}} にすぐ反映されます", "stackReselectHint": "CC Switch がクライアント設定を書き換えるたびに、モデル選択で集約したモデルを選び直してください", "stackPartial": "一部だけ書き込まれました。次の操作時または CC Switch の再起動時に完了します", "stackFailed": "集約したモデルの保存に失敗しました:{{error}}", @@ -387,7 +387,7 @@ "enableFeature": "メインページにルーティング切り替えを表示", "enableFeatureDescription": "有効にすると、メインページ上部にルーティングとフェイルオーバーの切り替えが表示されます", "enableStackMode": "メインページに集約モードのスイッチを表示", - "enableStackModeDescription": "オンにすると、Claude Code と Codex のメインページのスイッチが集約モードになります。OpenCode のように複数のプロバイダーを追加でき、それらのモデルがすべてクライアントのモデル一覧に表示されます(クライアント再起動後に反映)。フェイルオーバーは行いません。ローカルルーティングのスイッチとはどちらか一方のみで、切り替え時はオンになっている Claude Code、Codex をいったん直接接続に戻します", + "enableStackModeDescription": "オンにすると、Claude Code と Codex のメインページのスイッチが集約モードになります。OpenCode のように複数のプロバイダーを追加でき、それらのモデルがすべてクライアントのモデル一覧に表示されます(Codex は再起動後に反映)。フェイルオーバーは行いません。ローカルルーティングのスイッチとはどちらか一方のみで、切り替え時はオンになっている Claude Code、Codex をいったん直接接続に戻します", "enableFailoverToggle": "メインページにフェイルオーバー切り替えを表示", "enableFailoverToggleDescription": "有効にすると、メインページ上部にフェイルオーバー切り替えが独立して表示されます", "running": "実行中", @@ -1548,7 +1548,7 @@ "modelMappingHint": "表示名は /model メニューだけに影響します。1M は Claude Code へのコンテキスト能力宣言です。", "stackModelsLabel": "モデル一覧", "addStackModel": "手動で追加", - "stackModelsHint": "これらのモデルは Claude Code の /model に表示され、選ぶとリクエストがこのプロバイダーへ直接送られます。先頭(★)がこのプロバイダーのデフォルトモデルで、このプロバイダーがデフォルトのとき Claude Code の起動時とバックグラウンドタスクで使われます。変更後は Claude Code を再起動してください。", + "stackModelsHint": "これらのモデルは Claude Code の /model に表示され、選ぶとリクエストがこのプロバイダーへ直接送られます。先頭(★)がこのプロバイダーのデフォルトモデルで、このプロバイダーがデフォルトのとき Claude Code の起動時とバックグラウンドタスクで使われます。", "stackModelsEmpty": "モデルが未設定です:集約に追加してもモデル選択には表示されません", "stackModelPlaceholder": "例: deepseek-v4-pro", "stackModelDefault": "デフォルトモデル", @@ -3471,10 +3471,6 @@ "stackMode": { "enabled": "{{app}} を集約モードにしました", "exitedToDirect": "{{apps}} を直接接続に戻しました", - "tooltip": { - "active": "{{appLabel}} の集約モードがオンです:追加したプロバイダーのモデルがモデル一覧に表示されます(クライアント再起動後に反映)", - "inactive": "集約モードに入る:複数のプロバイダーを追加し、それらのモデルをすべて {{appLabel}} のモデル一覧に表示します" - }, "ariaLabel": "{{appLabel}} の集約モード", "failoverUnavailable": "{{appLabel}} は集約モードのため、フェイルオーバーは行いません。キューと設定は保持され、ルーティングモードに戻すと再び有効になります", "codexStale": { @@ -3972,6 +3968,7 @@ "toast": { "enteredRoute": "{{app}} のルーティングを開始しました → {{provider}}。", "enteredStack": "{{app}} を集約に切り替えました。{{app}} を再起動すると /model に集約したモデルが表示されます。", + "enteredStackLive": "{{app}} を集約に切り替えました。集約したモデルが /model に表示されます。", "exited": "直接接続に戻しました:{{app}} は {{provider}} に直接接続しています。", "defaultRemembered": "集約に切り替えると、{{provider}} が既定のプロバイダーになります。", "restartHint": "{{app}} の再起動後に反映されます。", @@ -3994,7 +3991,6 @@ "stackNoteMembers": "ほかに {{names}} の計 {{count}} モデルが、ccs- プレフィックス付きで /model に表示されます。", "stackNoteEmpty": "ほかのプロバイダーはまだ追加されていないため、切り替え後は既定のプロバイダーだけです。あとで「集約」タブで追加できます。", "stackNoteNoFailover": "集約ではフェイルオーバーしません。キューと設定は保持され、ルーティングに戻すと復元されます。", - "stackNoteRestart": "{{app}} を再起動すると集約したモデルが表示されます。", "stackNoteRestartCodex": "{{app}} を再起動すると集約したモデルが表示されます。デスクトップ版は完全に終了してから開き直してください。", "stackToRouteNote": "集約したモデルは /model から外れます(リストは保持)。選択中の集約したモデルは通常のモデルに戻す必要があります。", "confirmRoute": "ルーティングを開始", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 6914edae5..9af79b0f1 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -110,7 +110,7 @@ "stackBadgeHint": "在 {{client}} 的模型選擇器裡選擇:{{models}}", "stackBadgeNoModels": "這家沒有設定模型,不會出現在模型選擇器裡", "stackSaved": "已儲存,重新啟動 {{client}} 後生效", - "stackModelsChanged": "聚合的模型有變動,重新啟動 {{client}} 後生效", + "stackSavedLive": "已儲存,{{client}} 裡立即生效", "stackReselectHint": "CC Switch 每次改寫用戶端設定後,如果模型選擇器裡選的是聚合的模型,需要重新選一次", "stackPartial": "已部分寫入,將在下次操作或重新啟動 CC Switch 時補完", "stackFailed": "聚合的模型儲存失敗:{{error}}", @@ -387,7 +387,7 @@ "enableFeature": "在主頁面顯示本地路由開關", "enableFeatureDescription": "開啟後,主頁面頂部將顯示路由和故障轉移開關", "enableStackMode": "在主頁面顯示聚合模式開關", - "enableStackModeDescription": "開啟後,Claude Code 和 Codex 的主頁面開關換成聚合模式:像 OpenCode 一樣新增多家供應商,它們的模型都會出現在用戶端的模型清單裡(重新啟動用戶端後生效),不做故障轉移。和本機路由開關二選一,切換時已開啟的 Claude Code、Codex 會先退回直連", + "enableStackModeDescription": "開啟後,Claude Code 和 Codex 的主頁面開關換成聚合模式:像 OpenCode 一樣新增多家供應商,它們的模型都會出現在用戶端的模型清單裡(Codex 需要重新啟動後生效),不做故障轉移。和本機路由開關二選一,切換時已開啟的 Claude Code、Codex 會先退回直連", "enableFailoverToggle": "在主頁面顯示故障轉移開關", "enableFailoverToggleDescription": "開啟後,主頁面頂部將獨立顯示故障轉移開關", "running": "執行中", @@ -1549,7 +1549,7 @@ "modelMappingHint": "顯示名稱只影響 /model 選單;1M 只是提供給 Claude Code 的上下文能力聲明。", "stackModelsLabel": "模型列表", "addStackModel": "手動新增", - "stackModelsHint": "這些模型會出現在 Claude Code 的 /model 裡,選中後請求直達這家。第一個(★)是這家的預設模型:這家被設為預設時,Claude Code 啟動和背景任務都用它。修改後需要重新啟動 Claude Code。", + "stackModelsHint": "這些模型會出現在 Claude Code 的 /model 裡,選中後請求直達這家。第一個(★)是這家的預設模型:這家被設為預設時,Claude Code 啟動和背景任務都用它。", "stackModelsEmpty": "未設定模型:把這家加入聚合後,模型選擇器裡不會多出它的模型", "stackModelPlaceholder": "例如 deepseek-v4-pro", "stackModelDefault": "預設模型", @@ -3472,10 +3472,6 @@ "stackMode": { "enabled": "{{app}} 已進入聚合模式", "exitedToDirect": "{{apps}} 已退回直連", - "tooltip": { - "active": "{{appLabel}} 已開啟聚合模式:新增的供應商的模型出現在模型清單裡,重新啟動用戶端後生效", - "inactive": "進入聚合模式:新增多家供應商,它們的模型都出現在 {{appLabel}} 的模型清單裡" - }, "ariaLabel": "{{appLabel}} 聚合模式", "failoverUnavailable": "{{appLabel}} 在聚合模式下不做故障轉移。佇列和設定保留,回到路由模式後恢復", "codexStale": { @@ -3973,6 +3969,7 @@ "toast": { "enteredRoute": "{{app}} 已開始路由 → {{provider}}。", "enteredStack": "{{app}} 已切換到聚合模式。重新啟動 {{app}} 後,聚合的模型會出現在 /model 裡。", + "enteredStackLive": "{{app}} 已切換到聚合模式,聚合的模型已出現在 /model 裡。", "exited": "已回到直連:{{app}} 現在直接連線 {{provider}}。", "defaultRemembered": "切換到聚合模式後,預設供應商是 {{provider}}。", "restartHint": "重新啟動 {{app}} 後生效。", @@ -3995,7 +3992,6 @@ "stackNoteMembers": "另有 {{names}} 共 {{count}} 個模型,會以 ccs- 前綴出現在 /model 裡。", "stackNoteEmpty": "還沒有加入其他供應商,切換後只有預設供應商;可以之後在「聚合」分頁裡加入。", "stackNoteNoFailover": "聚合模式不做故障轉移;佇列和設定會保留,回到路由模式後恢復。", - "stackNoteRestart": "需要重新啟動 {{app}} 才能看到聚合的模型。", "stackNoteRestartCodex": "需要重新啟動 {{app}} 才能看到聚合的模型;桌面版要徹底結束後重新開啟。", "stackToRouteNote": "聚合的模型會從 /model 撤下(名單保留);已選中的聚合模型需要改回一般模型。", "confirmRoute": "開始路由", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 90731ba6d..3e240d93d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -110,7 +110,7 @@ "stackBadgeHint": "在 {{client}} 的模型选择器里选择:{{models}}", "stackBadgeNoModels": "这家没有配置模型,不会出现在模型选择器里", "stackSaved": "已保存,重启 {{client}} 后生效", - "stackModelsChanged": "聚合的模型有变化,重启 {{client}} 后生效", + "stackSavedLive": "已保存,{{client}} 里立即生效", "stackReselectHint": "CC Switch 每次改写客户端配置后,如果模型选择器里选的是聚合的模型,需要重新选一次", "stackPartial": "已部分写入,将在下次操作或重启 CC Switch 时补完", "stackFailed": "聚合的模型保存失败:{{error}}", @@ -387,7 +387,7 @@ "enableFeature": "在主页面显示本地路由开关", "enableFeatureDescription": "开启后,主页面顶部将显示路由和故障转移开关", "enableStackMode": "在主页面显示聚合模式开关", - "enableStackModeDescription": "开启后,Claude Code 和 Codex 的主页面开关换成聚合模式:像 OpenCode 一样添加多家供应商,它们的模型都会出现在客户端的模型列表里(重启客户端后生效),不做故障转移。和本地路由开关二选一,切换时已开启的 Claude Code、Codex 会先退回直连", + "enableStackModeDescription": "开启后,Claude Code 和 Codex 的主页面开关换成聚合模式:像 OpenCode 一样添加多家供应商,它们的模型都会出现在客户端的模型列表里(Codex 需要重启后生效),不做故障转移。和本地路由开关二选一,切换时已开启的 Claude Code、Codex 会先退回直连", "enableFailoverToggle": "在主页面显示故障转移开关", "enableFailoverToggleDescription": "开启后,主页面顶部将独立显示故障转移开关", "running": "运行中", @@ -1548,7 +1548,7 @@ "modelMappingHint": "显示名称只影响 /model 菜单;1M 只是给 Claude Code 的上下文能力声明。", "stackModelsLabel": "模型列表", "addStackModel": "手动添加", - "stackModelsHint": "这些模型会出现在 Claude Code 的 /model 里,选中后请求直达这家。第一个(★)是这家的默认模型:这家被设为默认时,Claude Code 启动和后台任务都用它。修改后需要重启 Claude Code。", + "stackModelsHint": "这些模型会出现在 Claude Code 的 /model 里,选中后请求直达这家。第一个(★)是这家的默认模型:这家被设为默认时,Claude Code 启动和后台任务都用它。", "stackModelsEmpty": "未配置模型:把这家加入聚合后,模型选择器里不会多出它的模型", "stackModelPlaceholder": "例如 deepseek-v4-pro", "stackModelDefault": "默认模型", @@ -3471,10 +3471,6 @@ "stackMode": { "enabled": "{{app}} 已进入聚合模式", "exitedToDirect": "{{apps}} 已退回直连", - "tooltip": { - "active": "{{appLabel}} 已开启聚合模式:添加的供应商的模型出现在模型列表里,重启客户端后生效", - "inactive": "进入聚合模式:添加多家供应商,它们的模型都出现在 {{appLabel}} 的模型列表里" - }, "ariaLabel": "{{appLabel}} 聚合模式", "failoverUnavailable": "{{appLabel}} 在聚合模式下不做故障转移。队列和设置保留,回到路由模式后恢复", "codexStale": { @@ -3972,6 +3968,7 @@ "toast": { "enteredRoute": "{{app}} 已开始路由 → {{provider}}。", "enteredStack": "{{app}} 已切换到聚合模式。重启 {{app}} 后,聚合的模型出现在 /model 里。", + "enteredStackLive": "{{app}} 已切换到聚合模式,聚合的模型已出现在 /model 里。", "exited": "已回到直连:{{app}} 现在直接连接 {{provider}}。", "defaultRemembered": "切换到聚合模式后,默认供应商是 {{provider}}。", "restartHint": "重启 {{app}} 后生效。", @@ -3994,7 +3991,6 @@ "stackNoteMembers": "另有 {{names}} 共 {{count}} 个模型,会以 ccs- 前缀出现在 /model 里。", "stackNoteEmpty": "还没有添加其他供应商,切换后只有默认供应商;可以之后在「聚合」页签里添加。", "stackNoteNoFailover": "聚合模式不做故障转移;队列和设置会保留,回到路由模式后恢复。", - "stackNoteRestart": "需要重启 {{app}} 才能看到聚合的模型。", "stackNoteRestartCodex": "需要重启 {{app}} 才能看到聚合的模型;桌面版要彻底退出后重新打开。", "stackToRouteNote": "聚合的模型会从 /model 撤下(名单保留);已选中的聚合模型需要改回普通模型。", "confirmRoute": "开始路由", diff --git a/src/lib/query/proxy.ts b/src/lib/query/proxy.ts index 99222e278..36b678edb 100644 --- a/src/lib/query/proxy.ts +++ b/src/lib/query/proxy.ts @@ -97,7 +97,8 @@ function isStackWriteError(error: unknown): error is ProxyStackWriteError { } /** - * 把一家加入或移出 Stack 模型。客户端只在启动时读模型列表,成功后提示重启。失败分两种: + * 把一家加入或移出 Stack 模型。Claude Code 运行中就会读到改过的 settings.json,Codex 只在 + * 启动时读模型目录,成功后提示重启。失败分两种: * 什么都没改(弹后端的错误),已部分写入(下次操作或重启 CC Switch 时补完)。两种都按 * 后端的状态重新显示,不在前端假设名单不变。 */ @@ -117,9 +118,12 @@ export function useSetProxyStackMember() { }) => proxyApi.setProxyStackMember(appType, providerId, enabled), onSuccess: (notice, variables) => { toast.success( - t("provider.stackSaved", { - client: getAppLabel(variables.appType), - }), + t( + variables.appType === "codex" + ? "provider.stackSaved" + : "provider.stackSavedLive", + { client: getAppLabel(variables.appType) }, + ), { description: variables.enabled ? t("provider.stackReselectHint") diff --git a/tests/components/SwitchModePanel.test.tsx b/tests/components/SwitchModePanel.test.tsx index d9eb404ae..3fa80b5a6 100644 --- a/tests/components/SwitchModePanel.test.tsx +++ b/tests/components/SwitchModePanel.test.tsx @@ -1,12 +1,6 @@ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { QueryClientProvider, focusManager } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { http, HttpResponse } from "msw"; import { toast } from "sonner"; @@ -155,54 +149,6 @@ describe("SwitchModePanel — Stack mode", () => { ]); }); - it("reminds to restart Claude Code when Stack models change outside add / remove", async () => { - mockMode("stack", "route"); - let members = [ - { providerId: "route", modelIds: ["m-route-1"], route: true }, - { providerId: "kimi", modelIds: ["m-kimi"], route: false }, - ]; - server.use( - http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () => - HttpResponse.json({ active: true, members }), - ), - http.post(`${TAURI_ENDPOINT}/set_proxy_stack_member`, () => - HttpResponse.json(null), - ), - ); - - renderPanel("claude", { route: provider("route"), kimi: provider("kimi") }); - - // 移出名单:保存成功的提示已经说了要重启,不再提示 - const remove = await screen.findByTestId("remove-kimi"); - members = [members[0]]; - fireEvent.click(remove); - await screen.findByTestId("add-kimi"); - expect(toast.info).not.toHaveBeenCalled(); - - // 别处改了默认那家的模型:回到窗口时重查,提示重启 - members = [ - { - providerId: "route", - modelIds: ["m-route-1", "m-route-2"], - route: true, - }, - ]; - try { - act(() => { - focusManager.setFocused(false); - focusManager.setFocused(true); - }); - await waitFor(() => - expect(toast.info).toHaveBeenCalledWith( - "provider.stackModelsChanged", - expect.anything(), - ), - ); - } finally { - focusManager.setFocused(undefined); - } - }); - it("warns about Codex clients on an old model list only in Stack mode", async () => { const stack = (active: boolean) => http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () => diff --git a/tests/hooks/useStackModelsChangedHint.test.tsx b/tests/hooks/useStackModelsChangedHint.test.tsx deleted file mode 100644 index 303efbe7a..000000000 --- a/tests/hooks/useStackModelsChangedHint.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { toast } from "sonner"; -import { useStackModelsChangedHint } from "@/hooks/useStackModelsChangedHint"; -import type { ProxyStack } from "@/types/proxy"; - -vi.mock("sonner", () => ({ - toast: { - info: vi.fn(), - }, -})); - -function stackOf(...modelIds: string[][]): ProxyStack { - return { - active: true, - members: modelIds.map((ids, index) => ({ - providerId: `p${index}`, - modelIds: ids, - route: index === 0, - })), - }; -} - -type Props = { appId: string; stack?: ProxyStack; updatedAt: number }; - -function renderHint(initial: Props) { - return renderHook( - ({ appId, stack, updatedAt }: Props) => - useStackModelsChangedHint(appId, stack, updatedAt), - { initialProps: initial }, - ); -} - -describe("useStackModelsChangedHint", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("reminds to restart Claude Code when the published models change", () => { - const { rerender } = renderHint({ - appId: "claude", - stack: stackOf(["a"], ["b"]), - updatedAt: 1, - }); - // 第一次拿到名单只是记下来。 - expect(toast.info).not.toHaveBeenCalled(); - - // 重查结果没变(比如窗口重新获得焦点):不提示;换顺序也不算变。 - rerender({ appId: "claude", stack: stackOf(["b"], ["a"]), updatedAt: 2 }); - expect(toast.info).not.toHaveBeenCalled(); - - // 编辑了名单里一家的模型。 - rerender({ - appId: "claude", - stack: stackOf(["a"], ["b", "c"]), - updatedAt: 3, - }); - expect(toast.info).toHaveBeenCalledTimes(1); - expect(toast.info).toHaveBeenCalledWith( - "provider.stackModelsChanged", - expect.anything(), - ); - }); - - it("skips the refetch right after adding or removing a member", () => { - const { result, rerender } = renderHint({ - appId: "claude", - stack: stackOf(["a"]), - updatedAt: 1, - }); - result.current(); - rerender({ appId: "claude", stack: stackOf(["a"], ["b"]), updatedAt: 2 }); - expect(toast.info).not.toHaveBeenCalled(); - - // 跳过只管紧接着的一次:之后的变化照常提示。 - rerender({ appId: "claude", stack: stackOf(["a"]), updatedAt: 3 }); - expect(toast.info).toHaveBeenCalledTimes(1); - }); - - it("does not keep the skip past a refetch that changed nothing", () => { - const { result, rerender } = renderHint({ - appId: "claude", - stack: stackOf(["a"]), - updatedAt: 1, - }); - // 加入失败,重查结果没变:跳过在这一次用掉。 - result.current(); - rerender({ appId: "claude", stack: stackOf(["a"]), updatedAt: 2 }); - rerender({ appId: "claude", stack: stackOf(["a"], ["b"]), updatedAt: 3 }); - expect(toast.info).toHaveBeenCalledTimes(1); - }); - - it("stays quiet for Codex and outside Stack mode", () => { - const { rerender } = renderHint({ - appId: "codex", - stack: stackOf(["a"]), - updatedAt: 1, - }); - rerender({ appId: "codex", stack: stackOf(["a"], ["b"]), updatedAt: 2 }); - expect(toast.info).not.toHaveBeenCalled(); - - // 退出 Stack 模式再进来:重新记,不拿进来之前的名单比。 - const claude = renderHint({ - appId: "claude", - stack: stackOf(["a"]), - updatedAt: 1, - }); - claude.rerender({ appId: "claude", stack: undefined, updatedAt: 2 }); - claude.rerender({ - appId: "claude", - stack: stackOf(["a"], ["b"]), - updatedAt: 3, - }); - expect(toast.info).not.toHaveBeenCalled(); - }); -});