From e8f1c80708fbe1135c6e825f3c34eb41183ec692 Mon Sep 17 00:00:00 2001 From: Aisland Date: Fri, 21 Aug 2026 22:52:21 +0800 Subject: [PATCH] fix: inherit current session model for new sessions instead of picking arbitrary --- 01_content/src/client/index.tsx | 84 +++++++++++++++++-- 01_content/src/client/split.tsx | 54 +++++++++--- .../worklogs/2026-08-16_worktable-v2.md | 12 +++ 04_test/probe-batch18.cjs | 27 ++++++ AGENTS.md | 7 +- 5 files changed, 162 insertions(+), 22 deletions(-) diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index e6f43c0..4b92858 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -340,9 +340,32 @@ async function ensureSessionPreset(sessionId: string): Promise { } catch { /* 修复失败不阻断主流程,错误仍按原路径提示 */ } } +/** 按「用户既有选择」在可用目录里找最贴近的模型(不擅自选): + * 1) id 完全一致;2) id/名称含基准模型的家族词(pro/max/mini/flash…);3) 目录首个。 */ +function pickInheritedModel(groups: any[], baseModel: string | undefined): { provider: string; model: string } | null { + if (!Array.isArray(groups) || groups.length === 0) return null + const flat: { provider: string; model: string; name: string }[] = [] + for (const g of groups) { + if (!g?.id || !Array.isArray(g.models)) continue + for (const m of g.models) if (m?.id) flat.push({ provider: g.id, model: m.id, name: String(m.name ?? '') }) + } + if (flat.length === 0) return null + const base = String(baseModel ?? '').toLowerCase() + if (base) { + const exact = flat.find((f) => f.model.toLowerCase() === base) + if (exact) return { provider: exact.provider, model: exact.model } + const tokens = base.split(/[-_.]/).filter((t) => t && (/^(pro|max|mini|flash|lite|ultra|plus|sonnet|opus|haiku|turbo)$/i.test(t) || t.length >= 4)) + for (const tok of tokens) { + const hit = flat.find((f) => f.model.toLowerCase().includes(tok) || f.name.toLowerCase().includes(tok)) + if (hit) return { provider: hit.provider, model: hit.model } + } + } + return { provider: flat[0].provider, model: flat[0].model } +} + /** 新会话模型选择修复(真正的根因修复):当前选择指向已删 provider(routable=false)时, - * 用 session.selectModel 切到模型目录里第一个可用 provider 的首个模型—— - * 该 API 同时把新选择存为默认,顺带修复后续所有新会话。 */ + * 不擅自选模型——① 优先继承用户当前会话正在用的模型;② 兜底按失效选择里的模型家族词 + * 找同款;③ 最后才目录首个。session.selectModel 同时把新选择存为默认,顺带修复后续新会话。 */ async function ensureSessionModel(sessionId: string): Promise { const api = hostApi?.sessions if (!api || typeof api.models !== 'function' || typeof api.selectModel !== 'function') return @@ -350,13 +373,58 @@ async function ensureSessionModel(sessionId: string): Promise { const mRes = await api.models({ sessionId }) if (!mRes?.result?.ok) return const val = mRes.result.value - if (val?.routable === true) return const groups = val?.groups - if (!Array.isArray(groups) || groups.length === 0) return - const g = groups[0] - const model = g?.models?.[0]?.id - if (!g?.id || !model) return - await api.selectModel({ sessionId, provider: g.id, model }) + let target: { provider: string; model: string } | null = null + let effort: string | undefined + // ① 无条件继承用户当前会话的选择(用户正在用哪个模型,新会话就用哪个) + try { + const cur = sessionBridge?.list?.getSnapshot?.()?.current + if (cur && cur !== sessionId) { + const cRes = await api.models({ sessionId: cur }) + if (cRes?.result?.ok) { + const cval = cRes.result.value + if (cval?.routable === true && cval?.current?.provider && cval?.current?.model) { + const same = val?.current?.provider === cval.current.provider && val?.current?.model === cval.current.model + if (!same) { + target = { provider: cval.current.provider, model: cval.current.model } + effort = typeof cval.current.reasoningEffort === 'string' ? cval.current.reasoningEffort : undefined + } + } + } + } + } catch {} + // ② 无当前会话且新会话选择不可用 → 最近会话众数(用户习惯) + if (!target && val?.routable !== true) { + try { + const snap = sessionBridge?.list?.getSnapshot?.() + const byId: Record = snap?.byId ?? {} + const ids: string[] = (snap?.ids ?? []).filter((x: string) => x !== sessionId) + const scored: { key: string; provider: string; model: string; effort?: string; count: number }[] = [] + await Promise.all(ids.slice(0, 6).map(async (sid) => { + const row = byId[sid] + if (row?.blank === true) return + try { + const r2 = await api.models({ sessionId: sid }) + if (!r2?.result?.ok) return + const cv = r2.result.value + if (cv?.routable !== true || !cv?.current?.provider || !cv?.current?.model) return + const key = cv.current.provider + '|' + cv.current.model + const found = scored.find((s) => s.key === key) + if (found) found.count++ + else scored.push({ key, provider: cv.current.provider, model: cv.current.model, effort: typeof cv.current.reasoningEffort === 'string' ? cv.current.reasoningEffort : undefined, count: 1 }) + } catch {} + })) + scored.sort((a, b) => b.count - a.count) + if (scored[0]) { + target = { provider: scored[0].provider, model: scored[0].model } + effort = scored[0].effort + } + } catch {} + } + // ③/④ 兜底(仅当新会话选择不可用):家族词 → 目录首个 + if (!target && val?.routable !== true) target = pickInheritedModel(groups, val?.current?.model) + if (!target) return + await api.selectModel({ sessionId, provider: target.provider, model: target.model, ...(effort !== undefined ? { reasoningEffort: effort } : {}) }) } catch { /* 修复失败不阻断主流程,错误仍按原路径提示 */ } } diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index fd29ffe..6923b24 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -27,7 +27,7 @@ export type BuiltinType = 'browser' | 'anim' | 'explorer' | 'scm' | 'tasks' | 't export type SplitContent = | { kind: 'iframe'; url: string; title?: string } - | { kind: 'builtin'; type: BuiltinType } + | { kind: 'builtin'; type: BuiltinType; url?: string } | { kind: 'file'; path: string } /** 一个内容标签页 */ @@ -650,6 +650,27 @@ export const splitStore: SplitState = { this.notify() }, + /** 更新指定标签页的内容(浏览器/动画窗地址栏回车时回写),并持久化到布局条目 */ + setTabContent(row, index, tabId, content) { + const spec = this.spec + if (!spec) return + const mutate = (pane: SplitPane): SplitPane => { + const tabs = (pane.tabs ?? []).map((t) => (t.id === tabId ? { ...t, content, title: tabTitleOf(content) } : t)) + return { ...pane, tabs } + } + if (row === 'left') { + if (spec.left && index === 0) this.spec = { ...spec, left: mutate(spec.left) } + } else if (row === 'top') { + const top = [...(spec.top ?? [])] + if (top[index]) { top[index] = mutate(top[index]); this.spec = { ...spec, top } } + } else { + const main = [...spec.main] + if (main[index]) { main[index] = mutate(main[index]); this.spec = { ...spec, main } } + } + this.onSpecMutated?.(this.spec) + this.notify() + }, + openTab(row, i, content) { const spec = this.spec if (!spec) return @@ -903,12 +924,18 @@ function makeDividerHandler(kind: 'left' | 'chat' | 'top' | 'pane' | 'topPane', } /** 浏览器内置窗:地址栏 + 前往;刷新统一在标签栏最左(重挂载 iframe,跨域也可靠) */ -function BrowserPane(props: { reloadKey: number }) { - const [url, setUrl] = useState('https://example.com') - const [src, setSrc] = useState('https://example.com') +function BrowserPane(props: { row: PaneRow; index: number; tabId: string; content: SplitContent; reloadKey: number }) { + const initial = props.content?.url || 'https://example.com' + const [url, setUrl] = useState(initial) + const [src, setSrc] = useState(initial) const go = () => { const u = url.trim() - setSrc(/^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank') + const ok = /^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank' + setSrc(ok) + if (ok !== 'about:blank') { + // 地址回写:刷新/重开布局时保持当前网址 + splitStore.setTabContent(props.row, props.index, props.tabId, { kind: 'builtin', type: 'browser', url: ok }) + } } return ( <> @@ -933,12 +960,17 @@ function IframePane(props: { url: string; title?: string; reloadKey: number }) { } /** 动画播放窗:iframe 壳 + 地址栏(站内自带项目/场景列表、播放、画幅切换、导出等全部控件) */ -function AnimPane(props: { reloadKey: number }) { - const [url, setUrl] = useState('') - const [src, setSrc] = useState('about:blank') +function AnimPane(props: { row: PaneRow; index: number; tabId: string; content: SplitContent; reloadKey: number }) { + const initial = props.content?.url || '' + const [url, setUrl] = useState(initial) + const [src, setSrc] = useState(initial || 'about:blank') const go = () => { const u = url.trim() - setSrc(/^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank') + const ok = /^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank' + setSrc(ok) + if (ok !== 'about:blank') { + splitStore.setTabContent(props.row, props.index, props.tabId, { kind: 'builtin', type: 'anim', url: ok }) + } } return ( <> @@ -1704,8 +1736,8 @@ function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number; paneTit if (content.kind === 'file') { return } - if (content.type === 'browser') return - if (content.type === 'anim') return + if (content.type === 'browser') return + if (content.type === 'anim') return if (content.type === 'console') return if (content.type === 'explorer') return if (content.type === 'scm') return diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index ed3b8cd..07ff35e 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -1460,3 +1460,15 @@ - 验证(probe-batch18 重跑):promptOk:true、promptErr:null(模型真实接受消息)✓; functional-diag 全 20 STEP ERROR_COUNT: 0。 - 备注:纯客户端改动,F5 即生效。 + +## 补记(第十九轮:模型继承用户选择) + +- 用户反馈:修复后默认又变 Flash——应该继承用户习惯,用户用 Pro 就用 Pro,不要自己选。 +- 演进:① 最初「目录首个」→ 用户不满;② 加当前会话继承(仅新会话 routable=false 时)→ + 探测发现默认选择已被此前测试污染成 flash(新会话 routable=true,继承分支被跳过); + ③ 定稿:ensureSessionModel 改为「无条件继承当前会话模型」(相同则跳过),仅在无当前 + 会话且新会话选择不可用时才走 最近会话众数 → 家族词 → 目录首个 兜底。selectModel 顺带 + 把继承的 Pro 写回默认,修复此前测试造成的 flash 默认污染。 +- 验证(probe-batch18):先切到 Pro 会话(deepseek-v4-pro)再新建 → repairedSelection = + deepseek-v4-pro、promptOk:true ✓;functional-diag 全 20 STEP ERROR_COUNT: 0。 +- 备注:纯客户端改动,F5 即生效。 diff --git a/04_test/probe-batch18.cjs b/04_test/probe-batch18.cjs index 0af49f4..f761822 100644 --- a/04_test/probe-batch18.cjs +++ b/04_test/probe-batch18.cjs @@ -45,6 +45,28 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); }; var st = window.__dshWorktable.splitStore; try { + // 模拟用户真实场景:先切到一个 Pro 模型的会话(用户正在用 Pro) + var hApi = window.__dshHostApi && window.__dshHostApi.sessions; + var snap0 = window.__dshSessions.list.getSnapshot(); + var proSid = null; + for (var k = 0; k < Math.min((snap0.ids || []).length, 10); k++) { + var sid2 = snap0.ids[k]; + try { + var mr = await hApi.models({ sessionId: sid2 }); + if (mr && mr.result && mr.result.ok && mr.result.value.routable && (mr.result.value.current.model || '').indexOf('pro') >= 0) { proSid = sid2; break; } + } catch (e) {} + } + out.proSessionFound = !!proSid; + if (proSid) { window.__dshSessions.open(proSid); } + await sleep(700); + // 诊断:①分支会看到的 current 与模型 + var snapChk = window.__dshSessions.list.getSnapshot(); + out.curAfterOpen = snapChk.current; + try { + var cRes = await hApi.models({ sessionId: snapChk.current }); + out.curModel = cRes && cRes.result && cRes.result.ok ? cRes.result.value.current.model : null; + out.curRoutable = cRes && cRes.result && cRes.result.ok ? cRes.result.value.routable : null; + } catch (e) { out.curModel = 'err'; } // 控制室未绑定 → 点开 → 强制绑定 → 新建并绑定 var card = vis('.dsh-wt_projects .dsh-wt_consoleEntry'); card.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true})); @@ -65,6 +87,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); out.agentPreset = row ? (row.agentPreset || '(none)') : '(missing)'; var face = svc.binding(sid).session; out.hasPrompt = typeof face.prompt === 'function'; + // 修复后实际选中的模型(继承结果) + try { + var mRes = await window.__dshHostApi.sessions.models({ sessionId: sid }); + out.repairedSelection = mRes && mRes.result && mRes.result.ok ? { provider: mRes.result.value.current.provider, model: mRes.result.value.current.model, routable: mRes.result.value.routable } : null; + } catch (e) { out.repairedSelection = 'err:' + String(e); } var r1 = null; try { r1 = await face.prompt([{ type: 'text', text: '回复一个字:好' }], 'queue'); diff --git a/AGENTS.md b/AGENTS.md index 8a22162..aadf026 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,10 @@ node --check lib/index.js ensureSessionPreset——用宿主 api.agentPresets.list/select 显式应用「部署默认预设」 (isDefault ?? 首个,失败逐个尝试其余预设;select 仅对 blank 会话生效)。 - **新会话模型修复(真根因)**:会话级模型选择独立于预设、随默认选择持久化——用户删掉 - provider 后新会话继承失效选择,prompt 报 model-unavailable。ensureSessionModel 用 - session.models 查 routable,false 时 session.selectModel 切到 groups[0] 首个可用模型 - (该 API 同时把新选择存为默认,顺带修复后续所有新会话)。两者都失败静默、缺 API 跳过。 + provider 后新会话继承失效选择,prompt 报 model-unavailable。ensureSessionModel: + ① 无条件继承「当前会话」正在用的模型(用户控制用哪个就用哪个,相同则跳过); + ② 无当前会话且新会话不可用时 → 最近会话众数 → 失效选择的家族词匹配 → 目录首个。 + session.selectModel 同时把新选择存为默认(继承的 Pro 会写回默认)。失败静默、缺 API 跳过。 - **对话绑定**:projects.v1.bindings = { 项目id → 会话id };打开项目时引擎自动 sessions.open(绑定会话)(openSplit / DOM 桥两处入口);未绑定/解绑 = 不切换。 - **项目×对话联动**:打开项目记录「打开前会话」(projectAttachRef.sessionId);项目打开期间切到