From c0163bc5073c71f51d3b1bd3d1fb225139de6f18 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 20 Aug 2026 22:38:18 +0800 Subject: [PATCH] fix(opencode): switch session models without restart --- ...820-session-model-agent-hotfix-6e4c9a2f.md | 71 ++++++++ README.md | 4 +- electron/api/routes/opencode.ts | 32 +++- electron/opencode/client.ts | 10 ++ electron/opencode/project-agent-runtime.ts | 170 +++++++++++++----- src/pages/Chat/OpencodeChatPanel.tsx | 106 ++++++++--- src/pages/Chat/chat-command-registry.ts | 3 +- src/pages/Chat/use-chat-commands.ts | 10 ++ src/stores/opencode.ts | 54 ++++++ src/types/opencode.ts | 5 + tests/e2e/opencode-multichat-runtime.spec.ts | 33 +++- tests/unit/chat-command-registry.test.ts | 3 +- tests/unit/opencode-chat-panel.test.tsx | 49 +++-- tests/unit/opencode-client.test.ts | 29 +++ tests/unit/opencode-routes.test.ts | 114 +++++++++++- tests/unit/opencode-store.test.ts | 49 +++++ tests/unit/project-agent-runtime.test.ts | 56 ++++++ tests/unit/use-chat-commands.test.tsx | 12 ++ 18 files changed, 718 insertions(+), 92 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260820-session-model-agent-hotfix-6e4c9a2f.md diff --git a/.project-docs/30-worklog/tasks/20260820-session-model-agent-hotfix-6e4c9a2f.md b/.project-docs/30-worklog/tasks/20260820-session-model-agent-hotfix-6e4c9a2f.md new file mode 100644 index 0000000..3898ada --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260820-session-model-agent-hotfix-6e4c9a2f.md @@ -0,0 +1,71 @@ +# Task: Implement session model parity and partner hot reload + +## Identity + +- Task ID: 20260820-session-model-agent-hotfix-6e4c9a2f +- Mode: Feature +- Branch: codex/20260820-session-model-agent-hotfix-6e4c9a2f-session-model-agent-hotfix +- Worktree: D:\Datas\OthersProjects\makelore-session-model-agent-hotfix-6e4c9a2f +- Base commit: 1c6b00443631ad41929e79943bbea9bc7126b563 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Make the AI programming chat model selector and `/models` / `/model` commands switch the active OpenCode Session model through OpenCode's native session API. +- Preserve the Session-selected model across later prompts, project commands, compaction, Session switching, and Session-list hydration without rewriting partner or Provider configuration. +- Allow a newly created unique-id project Agent to execute in the current fresh runtime generation once the live Agent registry exposes that id. +- Keep same-id Agent edits, deletion/recreation, attached or unknown runtime generations, and missing live ids fail-closed until a fresh generation rollover. +- Add focused unit and Electron E2E coverage and synchronize README product behavior. + +## Intent And Constraints + +- Page model switching must have the same OpenCode Session semantics as the runtime model command and must not trigger runtime restart, reload, dispose, or Provider persistence. +- Partner `model` remains the default for a new Session; the active model of an existing Session is owned by OpenCode. +- Ordinary prompts and project commands must not resend a model override that would undo a Session switch. +- A live same-id registry entry is not evidence that edited content was reloaded. Hot acceptance is restricted to an id never seen in the current generation; removed ids remain known and cannot be recreated hot. +- Preserve manager-to-project lock ordering, bounded runtime acceptance, attached/unknown fail-closed behavior, and all existing project Agent prompt/template structures. +- Work remained isolated in the claimed feature worktree; canonical `.project-docs` files were not modified. + +## Outcome + +- Added an OpenCode client adapter for `POST /api/session/{sessionID}/model`, a Main-owned Host API route, and Renderer Store state/action for per-Session model switching. +- Replaced the read-only composer model label with a Session selector. The selector and `/models` / `/model` share one action; successful switches update only Session state and can be hydrated from OpenCode Session metadata. +- Removed automatic model overrides from ordinary prompt and project-command submissions. Compaction and attachment capability projection now use the active Session model. +- Changed project Agent readiness from a whole-manifest execution gate to per-Agent desired/applied tracking. Existing unchanged Agents remain usable when a peer is added, and a new unique id becomes usable only after it appears in the live registry. +- Preserved sticky pending behavior for same-id edits, missing live Agents, attached/unknown generations, and delete/recreate of an id. A new fresh runtime generation remains the authoritative reset. +- Updated README and the multi-chat Electron E2E fixture to document and exercise Session switching without prompt model overwrite. + +## Verification + +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm run lint:check` — passed with 0 errors and 6 pre-existing warnings in unrelated files. +- Focused unit suite for Agent runtime, routes, client, Store, command registry/hook, and ChatPanel — 405 tests passed. +- `corepack pnpm test` — 176 files, 2068 tests passed. +- `corepack pnpm run build:vite` — passed; only existing chunk-size/dynamic-import warnings were reported. +- `corepack pnpm run test:e2e -- tests/e2e/opencode-multichat-runtime.spec.ts` — 1 Electron E2E passed. +- `git diff --check` — passed; only configured LF-to-CRLF notices were emitted. + +## Follow-ups + +- Integration should reconcile the canonical OpenCode Agent readiness and Session model ownership descriptions listed below. + +## Promotion Candidates + +### Per-Agent runtime acceptance + +- Target canonical documents: `.project-docs/20-architecture/module-map.md` and `.project-docs/20-architecture/data-flow.md`. +- Proposal: replace the whole-manifest-only Agent readiness description with per-Agent desired/applied state. A unique id first seen in an owned fresh generation may be accepted after live registry discovery; same-id edits and delete/recreate remain pending until generation rollover. +- Evidence: `electron/opencode/project-agent-runtime.ts`, `tests/unit/project-agent-runtime.test.ts`, and the Agent readiness cases in `tests/unit/opencode-routes.test.ts`. +- Future impact: future Agent configuration work must preserve the distinction between identity discovery and same-id content reload, and must not restore a whole-manifest gate that blocks existing Agents when adding a peer. +- Semantic conflicts: canonical docs currently describe a whole desired/applied fingerprint and state that live id alone cannot prove content. The proposal refines this by using first-seen unique identity as the safe hot-add boundary while retaining the same-id restriction. +- Human confirmation required: no; this behavior was explicitly requested and is covered by regression tests. + +### OpenCode Session model ownership + +- Target canonical documents: `.project-docs/20-architecture/module-map.md`, `.project-docs/20-architecture/data-flow.md`, and `.project-docs/40-domain/business-rules.md`. +- Proposal: document partner model as a new-Session default and OpenCode Session model as the authority for subsequent turns. Page selection and `/models` / `/model` call the native Session switch endpoint and ordinary prompts/commands omit model overrides. +- Evidence: `electron/opencode/client.ts`, `electron/api/routes/opencode.ts`, `src/stores/opencode.ts`, `src/pages/Chat/OpencodeChatPanel.tsx`, focused unit tests, and `tests/e2e/opencode-multichat-runtime.spec.ts`. +- Future impact: Provider configuration and partner maintenance must remain separate from transient per-Session model selection; future prompt paths must not reintroduce implicit model fallback overrides. +- Semantic conflicts: none known beyond superseding the previous chat-composer read-only model behavior. +- Human confirmation required: no; the user explicitly approved parity with OpenCode model switching. diff --git a/README.md b/README.md index 9d1edd3..24cc92e 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode ### 项目伙伴与会话 - 新项目默认没有伙伴或 Agent;用户在项目内手动创建伙伴时必须填写名称、预设头像或本地头像、职责和精确的 `provider/model`。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;提示词与 Skill 属于后置高级设置。 -- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择;模型切换只能在伙伴维护弹窗中完成,聊天输入区仅展示当前伙伴模型。 +- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。伙伴模型是新会话默认值;聊天输入区的模型选择器以及 `/models`、`/model` 只切换当前 OpenCode Session 的后续轮次,不改写伙伴或 Provider 配置,也不要求重启运行时。 - 读取旧项目时会把仍缺少模型的伙伴从兼容保留的 `defaultModel` 自动迁移到伙伴自身配置,之后运行时只认伙伴模型。 - 技能资源入口使用扳手图标;点击已安装 Skill 后先展示其目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。 - 一个伙伴可以拥有多条互相独立的 OpenCode Session;伙伴、会话、归档时间、置顶和未读数分别由项目配置与 `.niancode/conversations.json` 保存。 @@ -129,7 +129,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - 会话历史按需加载:项目事件流重连只恢复当前选中或仍有活动任务的会话,旧会话在用户打开时从 OpenCode runtime 完整读取;Renderer 最多保留 8 个非活动会话的消息缓存,淘汰只影响内存副本,不删除或改写任何服务端历史。连接稳定时以事件流为准,只有断流才启用低频 REST 回退,并对同一会话的并发历史读取去重。 - 创建伙伴后先进入伙伴对话,首条消息发送时才懒创建 OpenCode Session;新会话从干净上下文开始,标题从“新对话”在首条消息发送后自动生成,也支持手动重命名。 - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。共享 runtime/provider 是否真正并发执行不同 Session 尚未经过自动化真实运行 smoke 验证,运行时仍可能自行串行、限流或拒绝请求。 -- 已在当前 fresh OpenCode 运行代际加载且通过 live Agent registry 校验的项目伙伴,会按 Session 独立提交和跟踪回复。若共享 runtime/provider 未确认新 Session 已开始,该 Session 会在 10 秒后单独终止并提示手动重试,不会自动重发或改写其他 Session 状态。运行期间新建、编辑或删除伙伴只更新项目配置与生成文件,不会自动重启、reload 或 dispose 共享运行时,以免打断其他 Session;这类变更会保持“等待运行时重新加载”,用户需等当前回复完成后手动重启运行时再发送。 +- 已在当前 fresh OpenCode 运行代际加载且通过 live Agent registry 校验的项目伙伴,会按 Session 独立提交和跟踪回复。若共享 runtime/provider 未确认新 Session 已开始,该 Session 会在 10 秒后单独终止并提示手动重试,不会自动重发或改写其他 Session 状态。运行期间创建唯一 ID 的新伙伴时,只要 OpenCode live Agent registry 已发现该 ID,就可在同一运行代际直接使用;已有伙伴的同 ID 内容编辑以及删除、重建仍保持“等待运行时重新加载”,用户需等当前回复完成后手动重启运行时。上述配置变更都不会自动 restart、reload 或 dispose 共享运行时,以免打断其他 Session。 - 归档伙伴或单个会话前必须确认;归档会停止对应运行、保留历史,并可从列表底部恢复。未读按会话记录,打开一个会话只清除它自己的未读数。 - OpenCode 的问题在当前会话内联处理;项目对话权限默认自动允许,遗留的待处理权限也会自动批准;上下文压缩在聊天时间线实际发生的位置显示为独立事件:自动压缩使用“正在优化对话”/“已优化对话”文案,手动执行 `/compact` 使用“正在压缩上下文”/“已压缩上下文”文案。压缩进行中使用弱化动画,完成后变为静态状态并保留在原位置;触发阈值与同一 Session 的消息排队语义保持不变。 diff --git a/electron/api/routes/opencode.ts b/electron/api/routes/opencode.ts index 3f16269..7df79de 100644 --- a/electron/api/routes/opencode.ts +++ b/electron/api/routes/opencode.ts @@ -1527,6 +1527,36 @@ export async function handleOpencodeRoutes( const sessionSummarizeMatch = url.pathname.match( /^\/api\/opencode\/sessions\/([^/]+)\/summarize$/, ); + const sessionModelMatch = url.pathname.match( + /^\/api\/opencode\/sessions\/([^/]+)\/model$/, + ); + if (sessionModelMatch && req.method === 'POST') { + try { + await coordinateRuntimeExecution(res, ctx, async (signal, _markPending, response) => { + signal.throwIfAborted(); + const body = await parseJsonBody<{ model?: unknown }>(req); + const model = parseRuntimePromptModel(body.model); + if (!model) { + sendJson(response, 400, { success: false, error: 'Invalid model reference' }); + return; + } + const client = await createClientForActiveProject(response, ctx, { signal }); + signal.throwIfAborted(); + if (!client) return; + const sessionID = decodeURIComponent(sessionModelMatch[1]); + await client.switchSessionModel(sessionID, model, { signal }); + signal.throwIfAborted(); + sendJson(response, 200, { + success: true, + model: `${model.providerID}/${model.modelID}`, + }); + }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + if (sessionSummarizeMatch && req.method === 'POST') { try { await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => { @@ -1985,7 +2015,7 @@ export async function handleOpencodeRoutes( sendJson(response, 400, { success: false, error: 'Invalid model reference' }); return; } - const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary); + const model = explicitModel; signal.throwIfAborted(); const files = normalizePromptFileParts(body.files); const userContext = typeof body.userContext === 'string' ? body.userContext.trim().slice(0, 2_000) : ''; diff --git a/electron/opencode/client.ts b/electron/opencode/client.ts index 3ac5744..3584f7e 100644 --- a/electron/opencode/client.ts +++ b/electron/opencode/client.ts @@ -271,6 +271,16 @@ export function createOpencodeClient(options: OpencodeClientOptions) { body: JSON.stringify(payload), signal: options?.signal, }), + switchSessionModel: ( + sessionID: string, + model: OpencodeModelReference, + options?: OpencodeRuntimeRequestOptions, + ): Promise => + request(`/api/session/${encodeURIComponent(sessionID)}/model`, { + method: 'POST', + body: JSON.stringify({ model }), + signal: options?.signal, + }), executeSessionCommand: ( sessionID: string, payload: ExecuteOpencodeSessionCommandInput, diff --git a/electron/opencode/project-agent-runtime.ts b/electron/opencode/project-agent-runtime.ts index c9c37e9..a2f2e53 100644 --- a/electron/opencode/project-agent-runtime.ts +++ b/electron/opencode/project-agent-runtime.ts @@ -24,6 +24,12 @@ interface ProjectAgentRuntimeState { desiredFingerprint: string; appliedFingerprint: string | null; bootstrapCandidateFingerprint: string | null; + desiredAgentHashes: Map; + desiredAgentDefinitions: Map; + appliedAgentHashes: Map; + bootstrapCandidateAgentHashes: Map | null; + hotAddCandidateAgentIds: Set; + knownAgentIds: Set; } export interface ProjectAgentRuntimeSnapshot extends ProjectAgentRuntimeState { @@ -56,6 +62,59 @@ function canonicalProjectPath(projectPath: string): string { } } +function buildProjectAgentHashes(config: ProjectConfig): Map { + return new Map(buildProjectAgentManifest(config).entries.map((entry) => [ + path.posix.basename(entry.relativePath, '.md'), + entry.contentHash, + ])); +} + +function buildProjectAgentDefinitions(config: ProjectConfig): Map { + return new Map(config.agents.map((agent) => [agent.id, JSON.stringify(agent)])); +} + +function appliedManifestMatchesDesired(state: ProjectAgentRuntimeState): boolean { + return [...state.desiredAgentHashes].every( + ([agentId, desiredHash]) => state.appliedAgentHashes.get(agentId) === desiredHash, + ); +} + +function refreshAppliedFingerprint(state: ProjectAgentRuntimeState): void { + state.appliedFingerprint = appliedManifestMatchesDesired(state) + ? state.desiredFingerprint + : null; +} + +function createRuntimeState( + manager: ProjectAgentRuntimeManager, + config: ProjectConfig, + options: { forcePending?: boolean } = {}, +): ProjectAgentRuntimeState { + const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0; + const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown'; + const manifest = buildProjectAgentManifest(config); + const desiredAgentHashes = buildProjectAgentHashes(config); + const desiredAgentDefinitions = buildProjectAgentDefinitions(config); + const authoritativeFresh = provenance === 'fresh' && !options.forcePending; + return { + runtimeGeneration, + runtimeGenerationProvenance: provenance, + desiredFingerprint: manifest.fingerprint, + appliedFingerprint: authoritativeFresh ? manifest.fingerprint : null, + bootstrapCandidateFingerprint: provenance === 'starting' && !options.forcePending + ? manifest.fingerprint + : null, + desiredAgentHashes, + desiredAgentDefinitions, + appliedAgentHashes: authoritativeFresh ? new Map(desiredAgentHashes) : new Map(), + bootstrapCandidateAgentHashes: provenance === 'starting' && !options.forcePending + ? new Map(desiredAgentHashes) + : null, + hotAddCandidateAgentIds: new Set(), + knownAgentIds: new Set(desiredAgentHashes.keys()), + }; +} + function getManagerMap(registry: WeakMap>, manager: object): Map { let entries = registry.get(manager); if (!entries) { @@ -109,28 +168,55 @@ function observeState( const states = getManagerMap(runtimeStates, manager as object); const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0; const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown'; - const desiredFingerprint = buildProjectAgentManifest(config).fingerprint; + const desiredManifest = buildProjectAgentManifest(config); + const desiredAgentHashes = buildProjectAgentHashes(config); + const desiredAgentDefinitions = buildProjectAgentDefinitions(config); const current = states.get(canonicalPath); if (!current || current.runtimeGeneration !== runtimeGeneration) { - const next = { - runtimeGeneration, - runtimeGenerationProvenance: provenance, - desiredFingerprint, - appliedFingerprint: provenance === 'fresh' ? desiredFingerprint : null, - bootstrapCandidateFingerprint: provenance === 'starting' ? desiredFingerprint : null, - }; + const next = createRuntimeState(manager, config); states.set(canonicalPath, next); return next; } - current.desiredFingerprint = desiredFingerprint; + for (const [agentId, desiredHash] of desiredAgentHashes) { + const previousDesiredHash = current.desiredAgentHashes.get(agentId); + if ( + current.desiredAgentDefinitions.get(agentId) === desiredAgentDefinitions.get(agentId) + && previousDesiredHash + && current.appliedAgentHashes.get(agentId) === previousDesiredHash + ) { + current.appliedAgentHashes.set(agentId, desiredHash); + } + if ( + !current.knownAgentIds.has(agentId) + && (provenance === 'fresh' || provenance === 'starting') + ) { + current.hotAddCandidateAgentIds.add(agentId); + } + current.knownAgentIds.add(agentId); + } + for (const agentId of current.hotAddCandidateAgentIds) { + if (!desiredAgentHashes.has(agentId)) current.hotAddCandidateAgentIds.delete(agentId); + } + for (const agentId of current.desiredAgentHashes.keys()) { + if (desiredAgentHashes.has(agentId)) continue; + current.appliedAgentHashes.delete(agentId); + current.bootstrapCandidateAgentHashes?.delete(agentId); + } + current.desiredFingerprint = desiredManifest.fingerprint; + current.desiredAgentHashes = desiredAgentHashes; + current.desiredAgentDefinitions = desiredAgentDefinitions; if (provenance !== 'fresh') { current.appliedFingerprint = null; } else if (current.runtimeGenerationProvenance === 'starting') { - current.appliedFingerprint = current.bootstrapCandidateFingerprint === desiredFingerprint - ? desiredFingerprint - : null; + const bootstrapHashes = current.bootstrapCandidateAgentHashes ?? new Map(); + current.appliedAgentHashes = new Map([...bootstrapHashes].filter( + ([agentId, hash]) => desiredAgentHashes.get(agentId) === hash, + )); + refreshAppliedFingerprint(current); } else if (current.runtimeGenerationProvenance !== 'fresh') { current.appliedFingerprint = null; + } else { + refreshAppliedFingerprint(current); } current.runtimeGenerationProvenance = provenance; return current; @@ -171,19 +257,19 @@ export async function observeProjectAgentRuntimeGeneration( const runtimeGeneration = manager.getRuntimeGeneration?.() ?? 0; const provenance: RuntimeGenerationProvenance = manager.getRuntimeGenerationProvenance?.() ?? 'unknown'; if (current.runtimeGeneration !== runtimeGeneration) { - current.runtimeGeneration = runtimeGeneration; - current.runtimeGenerationProvenance = provenance; - current.appliedFingerprint = provenance === 'fresh' ? current.desiredFingerprint : null; - current.bootstrapCandidateFingerprint = provenance === 'starting' - ? current.desiredFingerprint - : null; + const config = await loadValidProjectConfig(canonicalPath); + const next = createRuntimeState(manager, config); + states.set(canonicalPath, next); + return { projectPath: canonicalPath, ...next }; } else if (provenance !== 'fresh') { current.appliedFingerprint = null; current.runtimeGenerationProvenance = provenance; } else if (current.runtimeGenerationProvenance === 'starting') { - current.appliedFingerprint = current.bootstrapCandidateFingerprint === current.desiredFingerprint - ? current.desiredFingerprint - : null; + const bootstrapHashes = current.bootstrapCandidateAgentHashes ?? new Map(); + current.appliedAgentHashes = new Map([...bootstrapHashes].filter( + ([agentId, hash]) => current.desiredAgentHashes.get(agentId) === hash, + )); + refreshAppliedFingerprint(current); current.runtimeGenerationProvenance = provenance; } else if (current.runtimeGenerationProvenance !== 'fresh') { current.appliedFingerprint = null; @@ -200,13 +286,7 @@ export async function markProjectAgentRuntimePending( ): Promise { const canonicalPath = canonicalProjectPath(projectPath); return await withProjectRuntimeLock(manager as object, canonicalPath, async () => { - const state = { - runtimeGeneration: manager.getRuntimeGeneration?.() ?? 0, - runtimeGenerationProvenance: manager.getRuntimeGenerationProvenance?.() ?? 'unknown', - desiredFingerprint: buildProjectAgentManifest(config).fingerprint, - appliedFingerprint: null, - bootstrapCandidateFingerprint: null, - }; + const state = createRuntimeState(manager, config, { forcePending: true }); getManagerMap(runtimeStates, manager as object).set(canonicalPath, state); return { projectPath: canonicalPath, ...state }; }); @@ -224,13 +304,7 @@ export async function mutateProjectAgentRuntime( observeState(manager, canonicalPath, result.previousConfig); observeState(manager, canonicalPath, result.config); } else { - const state: ProjectAgentRuntimeState = { - runtimeGeneration: manager.getRuntimeGeneration?.() ?? 0, - runtimeGenerationProvenance: manager.getRuntimeGenerationProvenance?.() ?? 'unknown', - desiredFingerprint: buildProjectAgentManifest(result.config).fingerprint, - appliedFingerprint: null, - bootstrapCandidateFingerprint: null, - }; + const state = createRuntimeState(manager, result.config, { forcePending: true }); getManagerMap(runtimeStates, manager as object).set(canonicalPath, state); } return result.value; @@ -278,20 +352,32 @@ export async function acceptProjectAgentRuntime( const config = await loadValidProjectConfig(canonicalPath); signal?.throwIfAborted(); const state = observeState(manager, canonicalPath, config); - const configuredAgentIds = config.agents.map((agent) => agent.id); - if ( - !configuredAgentIds.includes(selectedAgentId) - || state.appliedFingerprint !== state.desiredFingerprint - ) { + const desiredHash = state.desiredAgentHashes.get(selectedAgentId); + if (!desiredHash) { + return { ready: false, runtimeGeneration: state.runtimeGeneration }; + } + const alreadyApplied = state.runtimeGenerationProvenance === 'fresh' + && state.appliedAgentHashes.get(selectedAgentId) === desiredHash; + const hotAddCandidate = state.runtimeGenerationProvenance === 'fresh' + && state.hotAddCandidateAgentIds.has(selectedAgentId); + if (!alreadyApplied && !hotAddCandidate) { return { ready: false, runtimeGeneration: state.runtimeGeneration }; } signal?.throwIfAborted(); const liveIds = liveAgentIds(await awaitAbortable(client.listAgents({ signal }), signal)); - if (configuredAgentIds.some((agentId) => !liveIds.has(agentId))) { - state.appliedFingerprint = null; + if (!liveIds.has(selectedAgentId)) { + if (alreadyApplied) { + state.appliedAgentHashes.delete(selectedAgentId); + refreshAppliedFingerprint(state); + } return { ready: false, runtimeGeneration: state.runtimeGeneration }; } + if (hotAddCandidate) { + state.appliedAgentHashes.set(selectedAgentId, desiredHash); + state.hotAddCandidateAgentIds.delete(selectedAgentId); + refreshAppliedFingerprint(state); + } signal?.throwIfAborted(); const value = await awaitAbortable(accept(), signal); return { diff --git a/src/pages/Chat/OpencodeChatPanel.tsx b/src/pages/Chat/OpencodeChatPanel.tsx index eba5b3b..e0de0c5 100644 --- a/src/pages/Chat/OpencodeChatPanel.tsx +++ b/src/pages/Chat/OpencodeChatPanel.tsx @@ -863,6 +863,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro const abortSession = useOpencodeStore((state) => state.abortSession); const deleteOpencodeSession = useOpencodeStore((state) => state.deleteSession); const sendSessionMessage = useOpencodeStore((state) => state.sendSessionMessage); + const sessionModelBySessionId = useOpencodeStore((state) => state.sessionModelBySessionId); + const switchSessionModel = useOpencodeStore((state) => state.switchSessionModel); const cancelQueuedSessionPrompt = useOpencodeStore((state) => state.cancelQueuedSessionPrompt); const renameSession = useOpencodeStore((state) => state.renameSession); const shareSession = useOpencodeStore((state) => state.shareSession); @@ -951,6 +953,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro const seenCompactionIdsBySessionRef = useRef(new Map>()); const lastCompactionAnnouncementSessionRef = useRef(null); const composerAttachmentInputRef = useRef(null); + const modelSelectorRef = useRef(null); const composerStopArmTimerRef = useRef(null); const userModelConfigSyncRef = useRef | null>(null); const localProxyModelConfigSyncedRef = useRef(false); @@ -1040,6 +1043,21 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro () => buildConfiguredModelOptions(providerAccounts, providerStatuses, providerDefaultAccountId), [providerAccounts, providerDefaultAccountId, providerStatuses], ); + const activeSessionModel = selectedSessionId + ? sessionModelBySessionId[selectedSessionId] ?? selectedProjectAgent?.model ?? '' + : selectedProjectAgent?.model ?? ''; + const selectableChatModelOptions = useMemo(() => { + if (!activeSessionModel || configuredChatModelOptions.some((option) => option.modelRef === activeSessionModel)) { + return configuredChatModelOptions; + } + return [{ + modelRef: activeSessionModel, + label: formatModelRefLabel(activeSessionModel), + runtimeProviderKey: activeSessionModel.split('/')[0] ?? '', + accountId: '', + capability: 'text' as const, + }, ...configuredChatModelOptions]; + }, [activeSessionModel, configuredChatModelOptions]); const canStopSession = canUseSessions && Boolean(selectedSessionId) && sessionBusy && composerStopArmed; const canUseVoiceInput = canUseSessions && Boolean(selectedSessionId || selectedAgentId) @@ -1128,7 +1146,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro hasUndoableUserMessage: Boolean(undoableUserMessage), runtimeReady, busy: sessionBusy || selectedSessionRunning, - hasModel: Boolean(selectedProjectAgent?.model?.trim()), + hasModel: Boolean(activeSessionModel.trim()), shared: Boolean(currentSession?.share?.url), reverted: Boolean(currentSession?.revert?.messageID), shareEnabled: cachedCommandCatalog?.shareEnabled ?? true, @@ -1596,10 +1614,10 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro const ensureChatModelConfigured = useCallback(async () => { if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return false; - if (selectedProjectAgent?.model?.trim()) return true; + if (activeSessionModel.trim()) return true; setModelConfigPromptOpen(true); return false; - }, [refreshWorksSquareUserModelConfigIfNeeded, selectedProjectAgent?.model]); + }, [activeSessionModel, refreshWorksSquareUserModelConfigIfNeeded]); const openSubscriptionUpgrade = useCallback(() => { void openWorksSquareSubscriptionUpgrade().catch(() => undefined); @@ -1644,6 +1662,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro return; } const configuredAgentModel = submissionAgent.model?.trim(); + const submissionModel = submissionSessionId + ? useOpencodeStore.getState().sessionModelBySessionId[submissionSessionId] ?? configuredAgentModel + : configuredAgentModel; if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return; if (!sessionStillMatches()) return; const latestProviderState = useProviderStore.getState(); @@ -1652,7 +1673,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro latestProviderState.statuses, latestProviderState.defaultAccountId, ); - if (!configuredAgentModel || !latestConfiguredChatModelOptions.some((option) => option.modelRef === configuredAgentModel)) { + if (!submissionModel || !latestConfiguredChatModelOptions.some((option) => option.modelRef === submissionModel)) { setModelConfigPromptOpen(true); toast.error('这个伙伴的模型不可用,请先更新伙伴配置。'); return; @@ -1723,7 +1744,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro await sendSessionMessage(submissionSessionId, nextDraft, { attachedFiles, agentId: submissionAgent.id, - model: configuredAgentModel, onHostAccepted, }); } catch { @@ -2085,6 +2105,32 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro }); }, []); + const openSessionModelSelector = useCallback(() => { + if (!selectedSessionId) throw new Error('请先选择会话'); + const selector = modelSelectorRef.current; + if (!selector) throw new Error('当前没有可用的模型选择器'); + selector.focus(); + try { + selector.showPicker?.(); + } catch { + // Focus is the supported fallback when the platform cannot open a native picker programmatically. + } + }, [selectedSessionId]); + + const handleSessionModelChange = useCallback(async (event: ChangeEvent) => { + const sessionId = selectedSessionId; + const model = event.target.value; + if (!sessionId || !model || model === activeSessionModel) return; + try { + await switchSessionModel(sessionId, model); + toast.success(`当前会话已切换到 ${formatModelRefLabel(model)}`); + } catch (switchError) { + toast.error('模型切换失败', { + description: normalizeCommandError(switchError), + }); + } + }, [activeSessionModel, selectedSessionId, switchSessionModel]); + const runCompact = useCallback(async () => { if (!(await ensureChatModelConfigured())) { throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。'); @@ -2092,12 +2138,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro const owner = submissionSessionRef.current; const sessionId = owner.id; if (!sessionId) throw new Error('请先选择会话'); - await compactSession(sessionId, selectedProjectAgent?.model ?? undefined); + await compactSession(sessionId, activeSessionModel || undefined); assertSubmissionSessionOwner( owner, submissionSessionRef.current, ); - }, [compactSession, ensureChatModelConfigured, selectedProjectAgent?.model]); + }, [activeSessionModel, compactSession, ensureChatModelConfigured]); const runShare = useCallback(async () => { const owner = submissionSessionRef.current; @@ -2281,13 +2327,11 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro return { status: 'cancelled' }; } const parts = buildProjectCommandPartsFromAttachedFiles(attachedFiles); - const model = selectedProjectAgent?.model; const agent = selectedProjectAgent?.id ?? currentSession?.agent; await executeProjectCommand(submissionSessionId, { command: rawName, arguments: rawArguments, ...(agent ? { agent } : {}), - ...(model ? { model } : {}), parts, }); if (!sameSubmissionSessionOwner( @@ -2303,12 +2347,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro ensureChatModelConfigured, executeProjectCommand, selectedProjectAgent?.id, - selectedProjectAgent?.model, ]); const commandActions = useMemo(() => ({ rename: runRename, timeline: runTimeline, + models: openSessionModelSelector, compact: runCompact, share: runShare, unshare: runUnshare, @@ -2329,6 +2373,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro runRename, runShare, runTimeline, + openSessionModelSelector, runUndo, runUnshare, toggleThinking, @@ -2964,7 +3009,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro ( composer.selectVariant(attachment.id, variant) )} @@ -3106,17 +3151,34 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro )}
- {selectedAgentId ? ( - - {selectedProjectAgent?.model ? formatModelRefLabel(selectedProjectAgent.model) : '未配置模型'} - + {selectedSessionId || selectedAgentId ? ( + ) : null}