From 8eff551bb699a7d03058cdcc1cf932a778fe5b7d Mon Sep 17 00:00:00 2001 From: m4 Date: Thu, 30 Jul 2026 09:48:34 +0800 Subject: [PATCH] docs(registry): implementation plan for mutually-exclusive sampling_override Co-Authored-By: Claude Opus 4.7 --- ...7-30-sampling-override-mutual-exclusion.md | 793 ++++++++++++++++++ 1 file changed, 793 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-sampling-override-mutual-exclusion.md diff --git a/docs/superpowers/plans/2026-07-30-sampling-override-mutual-exclusion.md b/docs/superpowers/plans/2026-07-30-sampling-override-mutual-exclusion.md new file mode 100644 index 0000000..98cb2b3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sampling-override-mutual-exclusion.md @@ -0,0 +1,793 @@ +# sampling_override 互斥覆盖 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把会话级 temperature/top_p 平铺覆盖重写为判别联合 `sampling_override`(默认/temperature/top_p 三选一),覆盖其一时另一参数连同注册表默认从请求中省略。 + +**Architecture:** 全链路统一 `sampling_override: { kind: "temperature" | "top_p", value: number } | null`:线程元数据 `model_selection` → CAS PATCH 校验 → 快照 `selection_hash` → resolver 单参数 override(另一参数 OMIT)→ adapter 参数契约校验 → `request_options`;聊天栏两个常显滑块换成"默认 | T | P"分段选择器 + 单滑块。设计文档:`docs/superpowers/specs/2026-07-30-sampling-override-design.md`。 + +**Tech Stack:** Python(pydantic / 自研 model_registry), Next.js WebUI(TypeScript / vitest)。 + +**两个仓库:** +- 后端:`/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist`(下称 BACKEND) +- 前端:`/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI`(下称 WEBUI) + +## Global Constraints + +- 不留兼容 shim:平铺 `temperature`/`top_p` 覆盖字段、`temperature_override`/`top_p_override` kwargs、旧 hash 签名全部移除。唯一兼容行为:BFF parse 与 useChat 镜像**容忍并丢弃**旧元数据里的平铺 `temperature`/`top_p` 键(归一化为 null,不报错)。 +- 值域:temperature ∈ [0,2];top_p ∈ (0,1];NaN/Inf 拒绝。模型级契约(如 Anthropic temperature ≤ 1)只在后端 adapter `ParameterRule` 校验,BFF/前端不硬编码。 +- `null` = 未覆盖,使用注册表(model → provider 默认)继承值,两者可能同时发送(注册表配置者责任,维持现状)。 +- 覆盖仅作用于 primary 角色;`inherit` 选择下不允许携带覆盖。 +- 被省略的参数走 `_resolve_nullable_parameter(name, rule, None)`:正常得 OMIT;若契约声明 `nullable=="forbidden"` 则抛错(快照创建 4xx)——这是有意的严格性。 +- 后端测试:`cd BACKEND && .venv/bin/python -m pytest -v`;前端测试:`cd WEBUI && npx vitest run `。 +- openapi.json 由 `scripts/export_model_registry_schema.py` 生成,schema 变动后必须重新生成(BACKEND 有同步测试)。 +- `setModelSelection(next)` 是**整体替换**语义,所有调用点必须 spread 现有 selection,否则会丢字段。 + +--- + +### Task 1: 后端 — SamplingOverride + resolver OMIT 语义 + 快照 hash + +**Files:** +- Modify: `BACKEND/EvoScientist/model_registry/schemas.py`(新增 `SamplingOverride`) +- Modify: `BACKEND/EvoScientist/model_registry/adapters.py:580-599`(签名与 docstring)、`648-662`(temperature/top_p 解析段) +- Modify: `BACKEND/EvoScientist/model_registry/resolver.py:100-119,137-147,194-201`(kwargs 透传) +- Modify: `BACKEND/EvoScientist/model_registry/snapshots.py:13-14,66-83,131-152,209-214,241-248`(docstring、请求模型、hash、create) +- Modify: `BACKEND/EvoScientist/model_registry/openapi.json`(脚本重新生成) +- Test: `BACKEND/tests/test_snapshots.py` + +**Interfaces:** +- Produces: + - `SamplingOverride(BaseModel)`:`kind: Literal["temperature","top_p"]`、`value: float`,validator 按 kind 校验值域并拒绝 NaN/Inf(定义于 schemas.py)。 + - `resolve_parameters(provider, model, spec, *, reasoning_effort_override=None, sampling_override: SamplingOverride | None = None)` + - `ModelRegistryResolver.resolve(..., sampling_override=None)`(`_resolve` 同步) + - `SnapshotCreateRequest.sampling_override: SamplingOverride | None = None`(替换旧平铺字段) + - `compute_selection_hash(primary, reasoning_effort=None, sampling_override=None)`;override 序列化为 `{"kind":...,"value":...}` 或 `null` +- Consumes: 无(计划起点)。 + +- [ ] **Step 1: 改写失败测试** + +`BACKEND/tests/test_snapshots.py` 做四处修改: + +(1) 文件顶部固定哈希常量(约 194-200 行)替换为新线协议值: + +```python +# SHA-256 of the canonical JSON +# '{"primary":null,"reasoning_effort":null,"sampling_override":null}' — +# the inherit selection with no overrides. Pinned as a literal to lock the +# wire contract. +INHERIT_SELECTION_HASH = ( + "417399175386cea86c726c1bd2b833bd951066eae2e8f419b99076e9c91724a6" +) +``` + +(2) 导入处补上 `SamplingOverride`(与 `compute_selection_hash` 同一个 import 块;若包 `__init__` 未导出,用 `from EvoScientist.model_registry.schemas import SamplingOverride`)和 pydantic 的 `ValidationError`。 + +(3) `test_temperature_top_p_overrides_freeze_into_primary`(279-316 行)整体替换为: + +```python + def test_sampling_override_freezes_and_omits_the_other(self, active_store): + # glm-5.2's model-specific contract caps temperature at 1, so add a + # generic-contract model on the same provider to exercise the + # override path (disabled → verify → enable, the standard flow). + store = active_store + registry = store.load_registry() + payload = registry.model_dump(mode="json") + payload["providers"][0]["models"].append( + { + "key": "glm-air", + "name": "GLM Air", + "upstream_model_id": "glm-air", + "enabled": False, + "runtime": _model_runtime(), + } + ) + registry = store.save_registry( + expected_revision=registry.revision, + registry=RegistryV4.model_validate(payload), + ) + _verify(store, registry, "zhipu-glm", "glm-air") + payload = registry.model_dump(mode="json") + payload["providers"][0]["models"][1]["enabled"] = True + registry = store.save_registry( + expected_revision=registry.revision, + registry=RegistryV4.model_validate(payload), + ) + service = SnapshotService(store, ModelRegistryResolver(store)) + ref = {"provider_id": "zhipu-glm", "model_key": "glm-air"} + + # temperature override: top_p (provider default 0.95) is omitted. + creation = service.create( + _request( + primary=ref, + sampling_override={"kind": "temperature", "value": 1.1}, + ) + ) + options = creation.snapshot.payload.primary.request_options + assert options.temperature == 1.1 + assert options.top_p is None + + # top_p override: temperature (provider default 0.7) is omitted. + creation = service.create( + _request( + run_request_id="req-2", + primary=ref, + sampling_override={"kind": "top_p", "value": 0.5}, + ) + ) + options = creation.snapshot.payload.primary.request_options + assert options.top_p == 0.5 + assert options.temperature is None + + def test_sampling_override_value_range_enforced(self): + for bad in ( + {"kind": "temperature", "value": -0.1}, + {"kind": "temperature", "value": 2.1}, + {"kind": "temperature", "value": float("nan")}, + {"kind": "top_p", "value": 0}, + {"kind": "top_p", "value": 1.01}, + ): + with pytest.raises(ValidationError): + SamplingOverride.model_validate(bad) +``` + +(4) `test_overrides_rejected_outside_model_contract`(318-323 行)与 `test_temperature_top_p_overrides_change_selection_hash`(325-336 行)替换为: + +```python + def test_overrides_rejected_outside_model_contract(self, service): + # glm-5.2 走模型专属契约:temperature 上限 1;1.5 通过请求模型 + # (0–2) 校验但被 adapter 契约拒绝。 + with pytest.raises(ModelRegistryError) as excinfo: + service.create( + _request(sampling_override={"kind": "temperature", "value": 1.5}) + ) + assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER + + def test_sampling_override_changes_selection_hash(self, service): + plain = service.create(_request()) + assert compute_selection_hash( + None, None, SamplingOverride(kind="temperature", value=0.5) + ) != plain.snapshot.selection_hash + assert compute_selection_hash( + None, None, SamplingOverride(kind="top_p", value=0.9) + ) != plain.snapshot.selection_hash + # 未覆盖(None)与缺省一致:不传覆盖时 hash 与旧语义相同。 + assert compute_selection_hash(None, None, None) == ( + plain.snapshot.selection_hash + ) +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cd BACKEND && .venv/bin/python -m pytest tests/test_snapshots.py -v -k "sampling or selection_hash or inherit" +``` + +预期:FAIL(`SnapshotCreateRequest` 无 `sampling_override` 字段;`compute_selection_hash` 旧签名;hash 常量不匹配)。 + +- [ ] **Step 3: schemas.py 新增 SamplingOverride** + +`BACKEND/EvoScientist/model_registry/schemas.py`:顶部加 `import math`;在 `ReasoningEffort` 定义(42 行)之后插入: + +```python +class SamplingOverride(BaseModel): + """Mutually-exclusive per-run sampling override (2026-07-30 design). + + Overriding one parameter omits the other — including its registry + default — from the request, because providers reject or misbehave when + temperature and top_p are set together. + """ + + kind: Literal["temperature", "top_p"] + value: float + + @model_validator(mode="after") + def _check_value_range(self) -> SamplingOverride: + if not math.isfinite(self.value): + raise ValueError("sampling override value must be finite") + if self.kind == "temperature" and not 0 <= self.value <= 2: + raise ValueError("temperature override must be within [0, 2]") + if self.kind == "top_p" and not 0 < self.value <= 1: + raise ValueError("top_p override must be within (0, 1]") + return self +``` + +- [ ] **Step 4: adapters.py resolve_parameters 重写** + +签名(580-588 行)把 `temperature_override`/`top_p_override` 两行替换为: + +```python + sampling_override: SamplingOverride | None = None, +``` + +docstring 末段("The three overrides ...")替换为: + +``` + The sampling override (snapshot creation only) replaces the model's + configured value for its ``kind`` and omits the other sampling + parameter entirely — registry defaults included — because temperature + and top_p must not be sent together. Overrides flow through the same + contract rules, so unsupported adapters still reject them. +``` + +temperature/top_p 解析段(648-662 行)替换为: + +```python + if sampling_override is not None and sampling_override.kind == "temperature": + temperature = _resolve_nullable_parameter( + "temperature", _parameter_rule(spec, "temperature"), sampling_override.value + ) + top_p = _resolve_nullable_parameter( + "top_p", _parameter_rule(spec, "top_p"), None + ) + elif sampling_override is not None: + top_p = _resolve_nullable_parameter( + "top_p", _parameter_rule(spec, "top_p"), sampling_override.value + ) + temperature = _resolve_nullable_parameter( + "temperature", _parameter_rule(spec, "temperature"), None + ) + else: + temperature = model.runtime.temperature + if temperature is None: + temperature = provider.runtime.default_temperature + temperature = _resolve_nullable_parameter( + "temperature", _parameter_rule(spec, "temperature"), temperature + ) + + top_p = model.runtime.top_p + if top_p is None: + top_p = provider.runtime.default_top_p + top_p = _resolve_nullable_parameter("top_p", _parameter_rule(spec, "top_p"), top_p) +``` + +`.schemas` import 块(39 行起)加 `SamplingOverride`。 + +- [ ] **Step 5: resolver.py 透传** + +`resolve`(100-119 行)与 `_resolve`(137-147 行)签名中 `temperature_override`/`top_p_override` 两行替换为 `sampling_override: SamplingOverride | None = None`,;调用处相应改为 `sampling_override=sampling_override`(`resolve` 内 117-118、`_resolve` 内 194-201 的 `resolve_parameters` 调用)。`SamplingOverride` 加入 `.schemas` import。 + +- [ ] **Step 6: snapshots.py 请求模型、hash 与 create** + +(1) `SnapshotCreateRequest`(79-83 行)两个平铺字段替换为: + +```python + # Mutually-exclusive per-run sampling override; ``None`` keeps the + # registry-configured values. Overriding one omits the other from the + # request. Out-of-contract values are rejected by the adapter rules at + # resolve time. + sampling_override: SamplingOverride | None = None +``` + +pydantic import 行(37)移除不再使用的 `Field`;`.schemas` import 加 `SamplingOverride`。 + +(2) `compute_selection_hash`(131-152 行): + +```python +def compute_selection_hash( + primary: ModelRef | None, + reasoning_effort: ReasoningEffort | None = None, + sampling_override: SamplingOverride | None = None, +) -> str: + """Hash the pre-resolution selection; inherit participates as ``null``.""" + encoded = json.dumps( + { + "primary": ( + None + if primary is None + else {"provider_id": primary.provider_id, "model_key": primary.model_key} + ), + "reasoning_effort": reasoning_effort, + "sampling_override": ( + None + if sampling_override is None + else {"kind": sampling_override.kind, "value": sampling_override.value} + ), + }, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() +``` + +(3) `create` 中 hash 调用(209-214)改为: + +```python + selection_hash = compute_selection_hash( + request.primary, + request.reasoning_effort, + request.sampling_override, + ) +``` + +resolver 调用(241-248)中两个旧 kwargs 行替换为: + +```python + sampling_override=request.sampling_override, +``` + +(4) 模块 docstring(13-14 行)`{primary, reasoning_effort, temperature, top_p}` 改为 `{primary, reasoning_effort, sampling_override}`。 + +- [ ] **Step 7: 重新生成 openapi.json** + +```bash +cd BACKEND && .venv/bin/python scripts/export_model_registry_schema.py +``` + +预期输出 `Wrote .../openapi.json`。 + +- [ ] **Step 8: 运行后端测试** + +```bash +cd BACKEND && .venv/bin/python -m pytest tests/test_snapshots.py tests/test_resolver.py tests/test_adapter_contracts.py tests/test_model_registry_http.py tests/test_snapshot_runtime.py -q +``` + +预期:全部 PASS(含继承时 `public_snapshot_view` 仍显示 temperature 0.7/top_p 0.95 的旧用例)。 + +- [ ] **Step 9: Commit** + +```bash +cd BACKEND && git add EvoScientist/model_registry/schemas.py EvoScientist/model_registry/adapters.py EvoScientist/model_registry/resolver.py EvoScientist/model_registry/snapshots.py EvoScientist/model_registry/openapi.json tests/test_snapshots.py +git commit -m "feat(registry): mutually-exclusive sampling_override frozen into run snapshots" +``` + +--- + +### Task 2: WebUI — 类型 + BFF 校验 + 客户端镜像 + runs 路由 + +**Files:** +- Modify: `WEBUI/src/lib/modelRegistry.ts:197-207`(`ThreadModelSelection`,新增 `SamplingOverride`) +- Modify: `WEBUI/src/lib/server/threadModelSelection.ts:32-81`(parse) +- Modify: `WEBUI/src/app/hooks/useChat.ts:176-206`(`readSelectionFromMetadata` 镜像) +- Modify: `WEBUI/src/app/api/conversations/[threadId]/runs/route.ts:220-239`(快照 POST body) +- Test: `WEBUI/src/lib/server/threadModelSelection.test.ts`、`WEBUI/src/app/api/conversations/[threadId]/runs/route.test.ts` + +**Interfaces:** +- Consumes: Task 1 的 `SnapshotCreateRequest.sampling_override`。 +- Produces: `SamplingOverride = { kind: "temperature"; value: number } | { kind: "top_p"; value: number }`;`ThreadModelSelection = "inherit" | { primary: ModelRef; reasoning_effort?: ReasoningEffort | null; sampling_override?: SamplingOverride | null }`。Task 3 消费。 + +- [ ] **Step 1: 改写失败测试** + +`threadModelSelection.test.ts`: + +(1) 四个旧期望对象里的 `temperature: null, top_p: null` 全部改为 `sampling_override: null`("keeps an explicit reasoning effort"、"normalizes auto and missing effort to null" 内 2 处、"tolerates and drops a legacy auxiliary key")。 + +(2) "keeps explicit temperature/top_p overrides"、"normalizes missing generation overrides to null"、"rejects out-of-range or non-finite generation overrides" 三个用例替换为: + +```typescript + it("keeps an explicit sampling override", () => { + expect( + parseThreadModelSelection({ + primary: REF, + sampling_override: { kind: "temperature", value: 1.2 }, + }) + ).toEqual({ + primary: REF, + reasoning_effort: null, + sampling_override: { kind: "temperature", value: 1.2 }, + }); + expect( + parseThreadModelSelection({ + primary: REF, + sampling_override: { kind: "top_p", value: 0.5 }, + }) + ).toEqual({ + primary: REF, + reasoning_effort: null, + sampling_override: { kind: "top_p", value: 0.5 }, + }); + }); + + it("normalizes a missing sampling override to null", () => { + expect(parseThreadModelSelection({ primary: REF })).toEqual({ + primary: REF, + reasoning_effort: null, + sampling_override: null, + }); + }); + + it("rejects an invalid sampling override", () => { + for (const bad of [ + { primary: REF, sampling_override: { kind: "temperature", value: -0.1 } }, + { primary: REF, sampling_override: { kind: "temperature", value: 2.1 } }, + { primary: REF, sampling_override: { kind: "temperature", value: Number.NaN } }, + { primary: REF, sampling_override: { kind: "top_p", value: 0 } }, + { primary: REF, sampling_override: { kind: "top_p", value: 1.01 } }, + { primary: REF, sampling_override: { kind: "top_k", value: 0.5 } }, + { primary: REF, sampling_override: { kind: "temperature" } }, + { primary: REF, sampling_override: "high" }, + ]) { + expect(() => parseThreadModelSelection(bad)).toThrowError( + /model_selection must be/ + ); + } + }); + + it("tolerates and drops legacy flat temperature/top_p keys", () => { + expect( + parseThreadModelSelection({ primary: REF, temperature: 1.2, top_p: 0.5 }) + ).toEqual({ + primary: REF, + reasoning_effort: null, + sampling_override: null, + }); + }); +``` + +`route.test.ts` 的 "forwards generation overrides into the run snapshot request"(346-393 行):metadata 里 `temperature: 1.2, top_p: 0.5` 两行改为 `sampling_override: { kind: "temperature", value: 1.2 }`;`toMatchObject` 里同样两行改为 `sampling_override: { kind: "temperature", value: 1.2 }`。 + +- [ ] **Step 2: 运行确认失败** + +```bash +cd WEBUI && npx vitest run src/lib/server/threadModelSelection.test.ts "src/app/api/conversations/[threadId]/runs/route.test.ts" +``` + +预期:FAIL(parse 丢弃/拒绝新字段;路由 body 无 `sampling_override`)。 + +- [ ] **Step 3: 实现类型与 parse** + +`modelRegistry.ts:197-207` 替换为: + +```typescript +/** Mutually-exclusive per-thread sampling override (2026-07-30 design): + * temperature and top_p must not be sent together, so overriding one + * omits the other from the request. */ +export type SamplingOverride = + | { kind: "temperature"; value: number } + | { kind: "top_p"; value: number }; + +/** ThreadModelSelection (design doc 7.2). The optional reasoning_effort and + * sampling_override are per-thread overrides frozen into the run snapshot + * for the primary role; null means the registry default. */ +export type ThreadModelSelection = + | "inherit" + | { + primary: ModelRef; + reasoning_effort?: ReasoningEffort | null; + sampling_override?: SamplingOverride | null; + }; +``` + +`threadModelSelection.ts`:`isTemperature`/`isTopP` 保留,新增 kind 判别;`parseThreadModelSelection` 改写: + +```typescript +function isSamplingOverride(value: unknown): value is SamplingOverride { + if (!isPlainObject(value)) return false; + if (value.kind === "temperature") return isTemperature(value.value); + if (value.kind === "top_p") return isTopP(value.value); + return false; +} + +/** Validates a browser-supplied `model_selection` payload (design doc 7.2). + * `reasoning_effort` is optional; "auto" is normalized away so toggling the + * picker back to Auto keeps the selection (and its snapshot hash) stable. + * `sampling_override` is an optional mutually-exclusive per-thread override, + * shape-checked here (temperature 0–2 / top_p (0,1]); model-level contracts + * are enforced server-side when the run snapshot is created. Legacy + * `auxiliary` and flat `temperature`/`top_p` keys are tolerated and + * dropped. */ +export function parseThreadModelSelection(value: unknown): ThreadModelSelection { + if (value === "inherit") return "inherit"; + if ( + isPlainObject(value) && + isModelRef(value.primary) && + (value.reasoning_effort === undefined || + value.reasoning_effort === null || + (typeof value.reasoning_effort === "string" && + EFFORTS.has(value.reasoning_effort as ReasoningEffort))) && + (value.sampling_override === undefined || + value.sampling_override === null || + isSamplingOverride(value.sampling_override)) + ) { + const effort = value.reasoning_effort; + const override = value.sampling_override; + return { + primary: { provider_id: value.primary.provider_id, model_key: value.primary.model_key }, + reasoning_effort: + typeof effort === "string" && effort !== "auto" + ? (effort as ReasoningEffort) + : null, + sampling_override: isSamplingOverride(override) + ? { kind: override.kind, value: override.value } + : null, + }; + } + throw new ThreadModelSelectionError( + "INVALID_REQUEST", + "model_selection must be \"inherit\" or { primary: ModelRef, reasoning_effort?: \"low\" | \"medium\" | \"high\" | null, sampling_override?: { kind: \"temperature\" | \"top_p\", value: number } | null }." + ); +} +``` + +(`SamplingOverride` 加入顶部 `@/lib/modelRegistry` 的 type import。) + +- [ ] **Step 4: useChat 客户端镜像** + +`useChat.ts:176-206` 的 candidate 解析改为: + +```typescript + if (raw && typeof raw === "object") { + // Legacy `auxiliary` and flat `temperature`/`top_p` keys are tolerated + // and ignored. + const candidate = raw as { + primary?: unknown; + reasoning_effort?: unknown; + sampling_override?: unknown; + }; + if (isModelRefShape(candidate.primary)) { + const effort = candidate.reasoning_effort; + const override = candidate.sampling_override as + | { kind?: unknown; value?: unknown } + | null + | undefined; + return { + selection: { + primary: candidate.primary, + reasoning_effort: + effort === "low" || effort === "medium" || effort === "high" + ? effort + : null, + sampling_override: + override && + (override.kind === "temperature" || override.kind === "top_p") && + typeof override.value === "number" && + Number.isFinite(override.value) + ? { kind: override.kind, value: override.value } + : null, + }, + revision, + }; + } + } +``` + +- [ ] **Step 5: runs 路由快照 body** + +`route.ts:231-238` 的 `temperature`/`top_p` 两段替换为: + +```typescript + sampling_override: + selectionRef === "inherit" + ? null + : (selectionRef.sampling_override ?? null), +``` + +- [ ] **Step 6: 运行测试 + 类型检查** + +```bash +cd WEBUI && npx vitest run src/lib/server/threadModelSelection.test.ts "src/app/api/conversations/[threadId]/runs/route.test.ts" && npx tsc --noEmit +``` + +预期:测试 PASS;tsc 此时会报 ChatInterface.tsx 引用已删除的 `temperature`/`top_p` 字段——这些在 Task 3 修复,本任务只要求两个测试文件 PASS 且新增代码自身类型正确。 + +- [ ] **Step 7: Commit** + +```bash +cd WEBUI && git add src/lib/modelRegistry.ts src/lib/server/threadModelSelection.ts src/lib/server/threadModelSelection.test.ts src/app/hooks/useChat.ts "src/app/api/conversations/[threadId]/runs/route.ts" "src/app/api/conversations/[threadId]/runs/route.test.ts" +git commit -m "feat(webui): mutually-exclusive sampling_override in thread model selection" +``` + +--- + +### Task 3: WebUI — 聊天栏"默认 | T | P"分段选择器 + +**Files:** +- Modify: `WEBUI/src/app/components/ChatInterface.tsx:2277-2306`(ReasoningEffortSlider 调用修 spread)、`2307-2376`(两个旧滑块挂载替换)、`2679-2796` 附近(`GenerationParamSlider` 之后新增 `SamplingOverridePicker`) + +**Interfaces:** +- Consumes: Task 2 的 `modelSelection`(含 `sampling_override`)、`setModelSelection`;`selectedCatalogEntry.default_temperature/default_top_p`(已存在,`selectableModels.find` memo,不动)。 +- Produces: 无新导出(页面内组件)。 + +项目无组件测试设施(无 testing-library/jsdom),本任务以 tsc + 全量 vitest + 浏览器手测验证。 + +- [ ] **Step 1: 修 ReasoningEffortSlider 的整体替换隐患** + +`setModelSelection` 是整体替换;现有调用(2288-2291)只传 `primary` + `reasoning_effort`,会丢掉 `sampling_override`。改为: + +```tsx + await setModelSelection({ + ...modelSelection, + reasoning_effort: effort, + }); +``` + +- [ ] **Step 2: 新增 SamplingOverridePicker 组件** + +在 `GenerationParamSlider` 定义之后追加: + +```tsx +function SamplingOverridePicker({ + disabled, + override, + fallbackTemperature, + fallbackTopP, + onCommit, +}: { + disabled: boolean; + /** Current per-thread override; null means the registry defaults apply. */ + override: SamplingOverride | null; + /** Registry-effective defaults used to anchor each slider. */ + fallbackTemperature: number | null; + fallbackTopP: number | null; + onCommit: (value: SamplingOverride | null) => void | Promise; +}) { + const mode: "default" | "temperature" | "top_p" = override?.kind ?? "default"; + + const select = (next: "default" | "temperature" | "top_p") => { + if (next === mode) return; + if (next === "default") { + void onCommit(null); + return; + } + const value = + next === "temperature" + ? (override?.kind === "temperature" ? override.value : null) ?? + fallbackTemperature ?? + 1 + : (override?.kind === "top_p" ? override.value : null) ?? + fallbackTopP ?? + 1; + void onCommit({ kind: next, value }); + }; + + const modes = [ + { key: "default", label: "默认" }, + { key: "temperature", label: "T" }, + { key: "top_p", label: "P" }, + ] as const; + + return ( + + {/* ≥sm: segmented control */} + + {modes.map(({ key, label }) => ( + + ))} + + {/* + select(event.target.value as "default" | "temperature" | "top_p") + } + className="sm:hidden rounded border border-border bg-transparent px-1 py-0.5 text-xs text-muted-foreground" + > + {modes.map(({ key, label }) => ( + + ))} + + {mode === "temperature" && ( + + void onCommit(value === null ? null : { kind: "temperature", value }) + } + /> + )} + {mode === "top_p" && ( + + void onCommit(value === null ? null : { kind: "top_p", value }) + } + /> + )} + + ); +} +``` + +注意:`GenerationParamSlider` 自身带 `hidden sm:flex`,小屏只出下拉(选档即按锚点值提交),不出现滑块——与 spec 的小屏行为一致。组件顶部 import 加 `SamplingOverride` 类型(`@/lib/modelRegistry`)。 + +- [ ] **Step 3: 替换挂载点** + +`ChatInterface.tsx:2307-2376` 两个 `{currentModel && ()}` 块整体替换为: + +```tsx + {currentModel && ( + { + if (modelSelection === "inherit") return; + try { + await setModelSelection({ + ...modelSelection, + sampling_override: value, + }); + toast.success( + value === null + ? "Sampling back to the registry default." + : value.kind === "temperature" + ? `Temperature set to ${value.value.toFixed(2)} (top_p omitted).` + : `Top P set to ${value.value.toFixed(2)} (temperature omitted).` + ); + } catch (err) { + toast.error( + err instanceof Error + ? `Couldn't update sampling: ${err.message}` + : "Couldn't update sampling — try again." + ); + } + }} + /> + )} +``` + +- [ ] **Step 4: 类型检查 + 全量测试** + +```bash +cd WEBUI && npx tsc --noEmit && npx vitest run +``` + +预期:tsc 干净;全量 vitest PASS。 + +- [ ] **Step 5: 浏览器手测** + +启动 WebUI(端口 4716)与 langgraph dev(6174,含 Task 1 后端代码,restart 生效;两个 service token 须对齐)。验证: +1. 选一个模型 → 聊天栏出现"默认 | T | P"分段选择器,初始在"默认",无滑块。 +2. 点 T → toast "Temperature set to ...(top_p omitted)." → 滑块出现并停在提交值;刷新页面后仍在 T 档(元数据持久化)。 +3. 拖滑块改值 → toast 更新;点数值清空回车 → 回到"默认"档。 +4. 切到 P → temperature 覆盖被替换;发一条消息 → 快照 `request_options` 只带 top_p(查 `public_snapshot_view` 或日志)。 +5. 切回"默认" → 发消息 → 注册表两个默认都恢复发送。 +6. inherit(未选模型)时选择器禁用;小屏(