diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index ded16f0dac..51a0f28b3c 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-model/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: fa972e01579a5608b968e13010bd5224f38bdfbc -README.zh.md: b1a4980dc74e4cb0a4d6607dabae58d37bb18792 +# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md +README.md: 4de0e787a79d28ef8e92ee757d8d366e964c74d3 +README.zh.md: 0eef4af91c69fdab1e04377244d6f256399caa75 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index fa972e0157..4de0e787a7 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat (a compact trigger + upward provider-grouped menu, figma 313:14108's ToggleButton chrome) both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance — the host-reported current target is the single fact both surfaces echo, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter (an older response never overwrites a newer one); provider-local catalog failures list inline while usable groups stay selectable; whole-request and selection failures surface on each entry's own retry face (the popup shell's error/retry, the seat menu's inline error) without forking the state. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. +Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat (a compact trigger + upward provider-grouped menu, figma 313:14108's ToggleButton chrome) both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance — the host-reported current target is the single fact both surfaces echo, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter (an older response never overwrites a newer one); a connection reset drops every resident projection and repulls the Host-restored target before displaying it again. Provider-local catalog failures list inline while usable groups stay selectable; whole-request and selection failures surface on each entry's own retry face (the popup shell's error/retry, the seat menu's inline error) without forking the state. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index b1a4980dc7..0eef4af91c 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位(紧凑触发器 + 向上展开的按提供方分组菜单,视觉取 figma 313:14108 的 ToggleButton)都通过同一个 `ModelDirectory` 实例经 `session.models` 加载会话的建议目录、经 `session.selectModel` 提交——host 报告的 current target 是两个界面共同回显的唯一事实,在任一入口切换,另一入口下次打开显示的就是新值。目录加载与选择共享一个代次计数器(旧响应永不覆盖新结果);提供方级目录失败内联列出,可用分组保持可选;整体失败与选择失败落各入口自己的重试面(popup 壳的 error/retry、坑位菜单的内联错误),状态不分叉。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。 +模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位(紧凑触发器 + 向上展开的按提供方分组菜单,视觉取 figma 313:14108 的 ToggleButton)都通过同一个 `ModelDirectory` 实例经 `session.models` 加载会话的建议目录、经 `session.selectModel` 提交——host 报告的 current target 是两个界面共同回显的唯一事实,在任一入口切换,另一入口下次打开显示的就是新值。目录加载与选择共享一个代次计数器(旧响应永不覆盖新结果);连接重置会先丢弃所有常驻目录投影,再重新拉取 Host 恢复的 target 后显示,避免继续呈现未消费的进程内选择。提供方级目录失败内联列出,可用分组保持可选;整体失败与选择失败落各入口自己的重试面(popup 壳的 error/retry、坑位菜单的内联错误),状态不分叉。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。 `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。 diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts index 73c84115e7..eaf83f1a44 100644 --- a/packages/client/ui-model/src/client/directory.ts +++ b/packages/client/ui-model/src/client/directory.ts @@ -116,6 +116,24 @@ export class ModelDirectory { this.store.update((s) => { s.effort = effort }) } + /** + * Drop the previous Host generation's projection and repull it. Clearing + * first prevents an unconsumed process-local selection from being displayed + * while the restarted Host has restored the last logged request target. + */ + resetConnected(): void { + if (this.disposed) return + ++this.generation + this.store.update((s) => { + s.current = null + s.groups = [] + s.failures = [] + s.status = 'idle' + s.error = null + }) + void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ }) + } + /** Scope teardown: late settlements lose write access to the store. */ dispose(): void { this.disposed = true diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index 28d8d951b9..cabeaf52e6 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -41,6 +41,9 @@ export class ModelService extends Service { */ constructor(ctx: Context) { super(ctx, 'models') + ctx.on('connection/reset', () => { + for (const directory of this.live.directories.values()) directory.resetConnected() + }) } /** diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index d04e001bd2..d4ae9619b7 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -74,6 +74,7 @@ async function bench() { contribution: () => contribution!, seat: () => seats.get('conversation.input.model')!, hostCurrent: () => current, + setHostCurrent: (target: ModelTarget) => { current = target }, } } @@ -132,6 +133,22 @@ describe('ui-model dual entry', () => { expect(b.ctx.models.directoryFor(sid('a')).store).toBe(faceA.directory) }) + it('drops an unconsumed local selection and restores the Host target after reconnect', async () => { + const b = await bench() + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + await face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' }) + b.setHostCurrent({ provider: 'deepseek', model: 'deepseek-v4-flash' }) + + b.ctx.emit('connection/reset') + expect(face.directory.getSnapshot()).toMatchObject({ current: null, status: 'loading' }) + await Promise.resolve() + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + status: 'ready', + }) + }) + it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => { const b = await bench() const first = b.mint('s1')