From e57ecd45882462818afabb4b6bae33a0bca133e2 Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 28 Jul 2026 16:59:37 +0800 Subject: [PATCH] docs(plan): per-thread temperature/top_p override implementation plan Co-Authored-By: Claude Opus 4.7 --- ...07-28-thread-generation-param-overrides.md | 1057 +++++++++++++++++ 1 file changed, 1057 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-28-thread-generation-param-overrides.md diff --git a/docs/superpowers/plans/2026-07-28-thread-generation-param-overrides.md b/docs/superpowers/plans/2026-07-28-thread-generation-param-overrides.md new file mode 100644 index 0000000..4e28b7b --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-thread-generation-param-overrides.md @@ -0,0 +1,1057 @@ +# 会话级 temperature/top_p 覆盖 实现计划 + +> **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(与已有 reasoning effort 覆盖同构),冻结进运行快照。 + +**Architecture:** 平铺字段复刻 reasoning_effort 链路:线程元数据 `model_selection` → CAS PATCH 校验 → 快照 `selection_hash` → resolver override → adapter 参数契约校验 → `request_options`。设计文档:`docs/superpowers/specs/2026-07-28-thread-generation-param-overrides-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 + +- temperature 形状校验范围 0–2(含);top_p ∈ (0,1]。模型级契约(如 Anthropic temperature ≤ 1)只在后端 adapter `ParameterRule` 校验,BFF/前端不硬编码。 +- `null` = 未覆盖,使用注册表(model → provider 默认)继承值。 +- 覆盖仅作用于 primary 角色;`inherit` 选择下不允许携带覆盖(BFF parse 拒绝)。 +- 后端测试:`.venv/bin/python -m pytest tests/test_snapshots.py -v`(在 BACKEND 目录);前端测试:`npx vitest run `(在 WEBUI 目录)。 +- openapi.json 由 `scripts/export_model_registry_schema.py` 生成,schema 变动后必须重新生成(BACKEND 有同步测试)。 + +--- + +### Task 1: 后端 — 快照冻结 temperature/top_p 覆盖 + +**Files:** +- Modify: `BACKEND/EvoScientist/model_registry/adapters.py:580-598`(`resolve_parameters` 签名与 temperature/top_p 解析段 646-656) +- Modify: `BACKEND/EvoScientist/model_registry/resolver.py:100-115,133-141,186-190`(`resolve`/`_resolve` 透传) +- Modify: `BACKEND/EvoScientist/model_registry/snapshots.py:66-78,126-143,200-202,229-234`(请求模型、hash、create) +- Modify: `BACKEND/EvoScientist/model_registry/openapi.json`(由脚本重新生成) +- Test: `BACKEND/tests/test_snapshots.py` + +**Interfaces:** +- Produces: + - `resolve_parameters(provider, model, spec, *, reasoning_effort_override=None, temperature_override: float | None = None, top_p_override: float | None = None)` + - `ModelRegistryResolver.resolve(..., temperature_override=None, top_p_override=None)` + - `SnapshotCreateRequest` 新字段 `temperature: float | None`(ge=0, le=2)、`top_p: float | None`(gt=0, le=1) + - `compute_selection_hash(primary, reasoning_effort=None, temperature=None, top_p=None)` +- Consumes: 无(计划起点)。 + +- [ ] **Step 1: 写失败测试** + +在 `BACKEND/tests/test_snapshots.py` 的 `TestCreate` 类中、`test_reasoning_effort_override_rejected_for_unsupported_adapter` 之后追加: + +```python + def test_temperature_top_p_overrides_freeze_into_primary(self, service): + creation = service.create(_request(temperature=1.1, top_p=0.5)) + options = creation.snapshot.payload.primary.request_options + assert options.temperature == 1.1 + assert options.top_p == 0.5 + + def test_overrides_rejected_outside_model_contract(self, service): + # glm-5.2 走 openai-compatible 通用契约:temperature 上限 2。 + with pytest.raises(ModelRegistryError) as excinfo: + service.create(_request(temperature=2.5)) + assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER + + def test_temperature_top_p_overrides_change_selection_hash(self, service): + plain = service.create(_request()) + assert compute_selection_hash(None, None, 0.5, None) != ( + plain.snapshot.selection_hash + ) + assert compute_selection_hash(None, None, None, 0.9) != ( + plain.snapshot.selection_hash + ) + # 未覆盖(None)与缺省一致:不传覆盖时 hash 与旧语义相同。 + assert compute_selection_hash(None, None, None, None) == ( + plain.snapshot.selection_hash + ) +``` + +同时把文件顶部的固定哈希常量更新为新线协议值(旧值对应两字段 JSON,新值对应四字段 JSON): + +```python +# SHA-256 of the canonical JSON +# '{"primary":null,"reasoning_effort":null,"temperature":null,"top_p":null}' — +# the inherit selection with no overrides. Pinned as a literal to lock the +# wire contract. +INHERIT_SELECTION_HASH = ( + "0e7a49530963e46f999f6ee07e91f3e9b91fca50dfd126b8cc8aebd64055d6cc" +) +``` + +注意:`test_reasoning_effort_override_changes_selection_hash` 断言的是"不等"关系,无需改动;但任何直接断言旧 INHERIT_SELECTION_HASH 字面量的用例会失败——检索该常量引用,语义均为 inherit 哈希,随常量更新即可。 + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cd BACKEND && .venv/bin/python -m pytest tests/test_snapshots.py -v -k "temperature or top_p or selection_hash" +``` + +预期:新用例 FAIL(`SnapshotCreateRequest` 无 temperature 字段,校验报错或 TypeError)。 + +- [ ] **Step 3: 实现 adapters.py override** + +`resolve_parameters` 签名与 docstring(adapters.py:580-598): + +```python +def resolve_parameters( + provider: ProviderConfig, + model: ModelConfig, + spec: AdapterParameterSpec, + *, + reasoning_effort_override: ReasoningEffort | None = None, + temperature_override: float | None = None, + top_p_override: float | None = None, +) -> ResolvedParameters: + """Resolve a model's runtime parameters against the matched contract. + + Applies the section 6.1 inheritance semantics (model value overrides the + provider default; provider ``null`` means the field is omitted, never + zero; ``reasoning_effort=auto`` inherits and is omitted when still auto) + and the save-time contract checks. Raises ``ModelRegistryError`` with a + stable section 9.5 code on any violation. + + The three overrides (snapshot creation only) replace the model's + configured values before inheritance; they flow through the same + contract rules, so unsupported adapters still reject them. + """ +``` + +temperature/top_p 解析段(adapters.py:646-656)改为覆盖优先: + +```python + temperature = ( + temperature_override + if temperature_override is not None + else model.runtime.temperature + ) + if temperature is None: + temperature = provider.runtime.default_temperature + temperature = _resolve_nullable_parameter( + "temperature", _parameter_rule(spec, "temperature"), temperature + ) + + top_p = top_p_override if top_p_override is not None else 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) +``` + +- [ ] **Step 4: 实现 resolver.py 透传** + +`resolve`(resolver.py:100-115): + +```python + def resolve( + self, + model_ref: ModelRef, + role: ModelRole = "primary", + *, + registry: RegistryV4 | None = None, + reasoning_effort_override: ReasoningEffort | None = None, + temperature_override: float | None = None, + top_p_override: float | None = None, + ) -> ResolvedModelConfig: + """Resolve an enabled, verified model into its frozen run config.""" + return self._resolve( + model_ref, + role, + registry=registry, + require_enabled=True, + reasoning_effort_override=reasoning_effort_override, + temperature_override=temperature_override, + top_p_override=top_p_override, + ) +``` + +`_resolve`(resolver.py:133-141)同样加两个关键字参数,并把调用(resolver.py:188-190)改为: + +```python + parameters = resolve_parameters( + provider, + model, + spec, + reasoning_effort_override=reasoning_effort_override, + temperature_override=temperature_override, + top_p_override=top_p_override, + ) +``` + +- [ ] **Step 5: 实现 snapshots.py 请求模型、hash 与 create** + +`SnapshotCreateRequest`(snapshots.py:66-78)追加: + +```python +from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt + + # Per-run generation overrides (thread-level selection); ``None`` keeps + # the registry-configured values. Out-of-contract values are rejected by + # the adapter rules at resolve time. + temperature: float | None = Field(default=None, ge=0, le=2) + top_p: float | None = Field(default=None, gt=0, le=1) +``` + +(把 `Field` 加入既有 pydantic import 行。) + +`compute_selection_hash`(snapshots.py:126-143): + +```python +def compute_selection_hash( + primary: ModelRef | None, + reasoning_effort: ReasoningEffort | None = None, + temperature: float | None = None, + top_p: float | 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, + "temperature": temperature, + "top_p": top_p, + }, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() +``` + +`create` 中 hash 调用(snapshots.py:200-202)与 resolver 调用(snapshots.py:229-234): + +```python + selection_hash = compute_selection_hash( + request.primary, + request.reasoning_effort, + request.temperature, + request.top_p, + ) +``` + +```python + primary_config = self._resolver.resolve( + primary_ref, + "primary", + registry=registry, + reasoning_effort_override=request.reasoning_effort, + temperature_override=request.temperature, + top_p_override=request.top_p, + ) +``` + +同时更新模块 docstring(snapshots.py:13-14)中 `{primary, reasoning_effort}` 为 `{primary, reasoning_effort, temperature, top_p}`。 + +- [ ] **Step 6: 重新生成 openapi.json** + +`SnapshotCreateRequest` schema 变了,同步测试会校验 openapi.json: + +```bash +cd BACKEND && .venv/bin/python scripts/export_model_registry_schema.py +``` + +预期输出 `Wrote .../openapi.json`。 + +- [ ] **Step 7: 运行后端测试** + +```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 -q +``` + +预期:全部 PASS。 + +- [ ] **Step 8: Commit** + +```bash +cd BACKEND && git add 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): freeze per-thread temperature/top_p overrides into run snapshots" +``` + +--- + +### Task 2: 后端 — SelectableModel 携带生成参数默认值 + +聊天栏滑块需要显示"注册表生效值"作为默认位置;`/api/models` 目前只下发 name/provider/capabilities,需要补上继承后的默认值。 + +**Files:** +- Modify: `BACKEND/EvoScientist/model_registry/http_api.py:172-178`(`SelectableModel`)、`893-900`(`_selectable_models` 构造) +- Modify: `BACKEND/EvoScientist/model_registry/openapi.json`(脚本重新生成) +- Test: `BACKEND/tests/test_model_registry_http.py:656-691` + +**Interfaces:** +- Consumes: 无。 +- Produces: `SelectableModel.default_temperature: float | None`、`SelectableModel.default_top_p: float | None`(继承语义:model.runtime 值 ?? provider 默认值;两者皆 None 则为 null)。WebUI Task 5 消费。 + +- [ ] **Step 1: 写失败测试** + +`test_model_registry_http.py` 的 `test_get_models_returns_only_selectable` 中,在 `assert glm["effective_capabilities"] == {...}` 之后追加: + +```python + # Generation defaults ride the catalog so the chat-bar sliders can show + # the registry-effective values before any override (model value wins + # over the provider default; qwen3 inherits nothing -> null). + assert glm["default_temperature"] == 0.7 + assert glm["default_top_p"] == 0.95 + qwen = next(m for m in models if m["model_ref"]["model_key"] == "qwen3") + assert qwen["default_temperature"] is None + assert qwen["default_top_p"] is None +``` + +- [ ] **Step 2: 运行确认失败** + +```bash +cd BACKEND && .venv/bin/python -m pytest tests/test_model_registry_http.py::test_get_models_returns_only_selectable -v +``` + +预期:FAIL(KeyError: 'default_temperature')。 + +- [ ] **Step 3: 实现** + +`SelectableModel`(http_api.py:172-178): + +```python +class SelectableModel(BaseModel): + """One entry of the section 9.1 model selector response.""" + + model_ref: ModelRef + name: str + provider_name: str + effective_capabilities: Capabilities + # Registry-effective generation defaults (model value wins over the + # provider default; both unset -> null). Lets chat UIs anchor their + # temperature/top_p sliders without a registry read. + default_temperature: float | None = None + default_top_p: float | None = None +``` + +`_selectable_models` 的 `models.append`(http_api.py:893-900): + +```python + models.append( + SelectableModel( + model_ref=item.model_ref, + name=model.name, + provider_name=provider.name, + effective_capabilities=item.effective_capabilities, + default_temperature=( + model.runtime.temperature + if model.runtime.temperature is not None + else provider.runtime.default_temperature + ), + default_top_p=( + model.runtime.top_p + if model.runtime.top_p is not None + else provider.runtime.default_top_p + ), + ) + ) +``` + +- [ ] **Step 4: 重新生成 openapi.json 并测试** + +```bash +cd BACKEND && .venv/bin/python scripts/export_model_registry_schema.py +.venv/bin/python -m pytest tests/test_model_registry_http.py -q +``` + +预期:全部 PASS。 + +- [ ] **Step 5: Commit** + +```bash +cd BACKEND && git add EvoScientist/model_registry/http_api.py EvoScientist/model_registry/openapi.json tests/test_model_registry_http.py +git commit -m "feat(registry): expose generation defaults on selectable models" +``` + +--- + +### Task 3: WebUI — ThreadModelSelection 类型 + BFF 校验 + 客户端镜像 + +**Files:** +- Modify: `WEBUI/src/lib/modelRegistry.ts:195-202`(`ThreadModelSelection`) +- Modify: `WEBUI/src/lib/server/threadModelSelection.ts:32-59`(`parseThreadModelSelection`) +- Modify: `WEBUI/src/app/hooks/useChat.ts:157-197`(`readSelectionFromMetadata` 镜像) +- Test: `WEBUI/src/lib/server/threadModelSelection.test.ts` + +**Interfaces:** +- Consumes: 无(纯形状扩展,后端 Task 1 已能接受这些字段)。 +- Produces: `ThreadModelSelection = "inherit" | { primary: ModelRef; reasoning_effort?: ReasoningEffort | null; temperature?: number | null; top_p?: number | null }`;`parseThreadModelSelection` 接受并归一化新字段(缺省/undefined → null)。Task 4、6 消费。 + +- [ ] **Step 1: 写失败测试** + +`threadModelSelection.test.ts` 的 describe 内追加: + +```typescript + it("keeps explicit temperature/top_p overrides", () => { + expect( + parseThreadModelSelection({ primary: REF, temperature: 1.2, top_p: 0.5 }) + ).toEqual({ + primary: REF, + reasoning_effort: null, + temperature: 1.2, + top_p: 0.5, + }); + }); + + it("normalizes missing generation overrides to null", () => { + expect(parseThreadModelSelection({ primary: REF })).toEqual({ + primary: REF, + reasoning_effort: null, + temperature: null, + top_p: null, + }); + }); + + it("rejects out-of-range or non-finite generation overrides", () => { + for (const bad of [ + { primary: REF, temperature: -0.1 }, + { primary: REF, temperature: 2.1 }, + { primary: REF, temperature: Number.NaN }, + { primary: REF, top_p: 0 }, + { primary: REF, top_p: 1.01 }, + { primary: REF, top_p: "high" }, + ]) { + expect(() => parseThreadModelSelection(bad)).toThrowError( + /model_selection must be/ + ); + } + }); +``` + +- [ ] **Step 2: 运行确认失败** + +```bash +cd WEBUI && npx vitest run src/lib/server/threadModelSelection.test.ts +``` + +预期:新用例 FAIL(现有 parse 丢弃新字段,toEqual 不匹配)。 + +- [ ] **Step 3: 实现类型与 parse** + +`modelRegistry.ts:195-202`: + +```typescript +/** ThreadModelSelection (design doc 7.2). The optional reasoning_effort, + * temperature, and top_p 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; + temperature?: number | null; + top_p?: number | null; + }; +``` + +`threadModelSelection.ts` 在 `isModelRef` 后新增校验助手并改写 `parseThreadModelSelection`: + +```typescript +function isTemperature(value: unknown): boolean { + return ( + typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 2 + ); +} + +function isTopP(value: unknown): boolean { + return ( + typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 1 + ); +} + +/** 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. + * `temperature`/`top_p` are optional per-thread overrides, shape-checked + * here (0–2 / (0,1]); model-level contracts are enforced server-side when + * the run snapshot is created. A legacy `auxiliary` key from pre-6.1 + * metadata is 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.temperature === undefined || + value.temperature === null || + isTemperature(value.temperature)) && + (value.top_p === undefined || value.top_p === null || isTopP(value.top_p)) + ) { + const effort = value.reasoning_effort; + 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, + temperature: + typeof value.temperature === "number" ? value.temperature : null, + top_p: typeof value.top_p === "number" ? value.top_p : null, + }; + } + throw new ThreadModelSelectionError( + "INVALID_REQUEST", + "model_selection must be \"inherit\" or { primary: ModelRef, reasoning_effort?: \"low\" | \"medium\" | \"high\" | null, temperature?: number | null, top_p?: number | null }." + ); +} +``` + +注意:已有用例 `toEqual({ primary: REF, reasoning_effort: "high" })` 等仍通过——`toEqual` 递归相等,返回值现在多两个 null 字段,需要同步更新旧用例期望,补上 `temperature: null, top_p: null`(共 4 处:"keeps an explicit reasoning effort"、"normalizes auto and missing effort to null" 内 2 处、"tolerates and drops a legacy auxiliary key")。 + +- [ ] **Step 4: 实现 useChat 客户端镜像** + +`useChat.ts:176-195` 的 candidate 解析改为: + +```typescript + if (raw && typeof raw === "object") { + // A legacy `auxiliary` key from pre-6.1 metadata is tolerated and ignored. + const candidate = raw as { + primary?: unknown; + reasoning_effort?: unknown; + temperature?: unknown; + top_p?: unknown; + }; + if (isModelRefShape(candidate.primary)) { + const effort = candidate.reasoning_effort; + return { + selection: { + primary: candidate.primary, + reasoning_effort: + effort === "low" || effort === "medium" || effort === "high" + ? effort + : null, + temperature: + typeof candidate.temperature === "number" && + Number.isFinite(candidate.temperature) + ? candidate.temperature + : null, + top_p: + typeof candidate.top_p === "number" && + Number.isFinite(candidate.top_p) + ? candidate.top_p + : null, + }, + revision, + }; + } + } +``` + +- [ ] **Step 5: 运行测试 + 类型检查** + +```bash +cd WEBUI && npx vitest run src/lib/server/threadModelSelection.test.ts && npx tsc --noEmit +``` + +预期:测试 PASS;tsc 干净(useChat 无独立测试文件,镜像逻辑由 tsc 与 Task 4 的路由测试间接覆盖)。 + +- [ ] **Step 6: 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 +git commit -m "feat(webui): accept temperature/top_p in thread model selection" +``` + +--- + +### Task 4: WebUI — runs 路由快照请求体携带覆盖 + +**Files:** +- Modify: `WEBUI/src/app/api/conversations/[threadId]/runs/route.ts:213-233`(快照 POST body) +- Test: `WEBUI/src/app/api/conversations/[threadId]/runs/route.test.ts` + +**Interfaces:** +- Consumes: Task 3 的 `readThreadModelSelection`(返回的 selection 现含 temperature/top_p)。 +- Produces: POST `/api/runtime-snapshots` body 新增 `temperature`/`top_p` 字段(inherit 或 null 覆盖时为 null),对接 Task 1 的 `SnapshotCreateRequest`。 + +- [ ] **Step 1: 写失败测试** + +`route.test.ts` 的 describe 内追加(放在现有用例之后): + +```typescript + it("forwards generation overrides into the run snapshot request", async () => { + const deployment = scopedDeployment(); + deployment.threadClient.threads.get.mockResolvedValue({ + thread_id: "thread-a", + metadata: { + workspace_scope_id: "00000000-0000-4000-8000-000000000010", + model_selection: { + primary: { provider_id: "zhipu-glm", model_key: "glm-5.2" }, + reasoning_effort: null, + temperature: 1.2, + top_p: 0.5, + }, + model_selection_revision: 3, + }, + }); + mocks.getActiveDeployment.mockResolvedValue(deployment); + + const request = new NextRequest( + "http://localhost/api/conversations/thread-a/runs", + { + method: "POST", + body: JSON.stringify({ input: "hello" }), + } + ); + const response = await routes.POST(request, context); + expect(response.status).toBe(201); + + const snapshotCall = mocks.configApiFetch.mock.calls.find( + ([, path, options]) => + path === "/api/runtime-snapshots" && + (options as { method?: string })?.method === "POST" + ); + expect(snapshotCall).toBeDefined(); + const body = (snapshotCall![2] as { body: Record }).body; + expect(body).toMatchObject({ + primary: { provider_id: "zhipu-glm", model_key: "glm-5.2" }, + reasoning_effort: null, + temperature: 1.2, + top_p: 0.5, + model_selection_revision: 3, + }); + }); +``` + +先确认现有测试如何构造 POST 请求(参考 `route.test.ts` 首个用例的请求构造方式,保持同样的 headers/body 形状;若现有用例使用不同的请求辅助函数,以其为准调整)。 + +- [ ] **Step 2: 运行确认失败** + +```bash +cd WEBUI && npx vitest run "src/app/api/conversations/[threadId]/runs/route.test.ts" +``` + +预期:新用例 FAIL(body 中无 temperature/top_p 字段)。 + +- [ ] **Step 3: 实现** + +`route.ts:213-233` 快照 POST body 改为: + +```typescript + const { body: snapshot } = + await configApiFetch( + actor, + "/api/runtime-snapshots", + { + method: "POST", + threadId, + body: { + run_request_id: runRequestId, + thread_id: threadId, + deployment_id: delegationDeploymentId(), + model_selection_revision: selection.revision, + primary: + selectionRef === "inherit" ? null : selectionRef.primary, + reasoning_effort: + selectionRef === "inherit" + ? null + : (selectionRef.reasoning_effort ?? null), + temperature: + selectionRef === "inherit" + ? null + : (selectionRef.temperature ?? null), + top_p: + selectionRef === "inherit" + ? null + : (selectionRef.top_p ?? null), + }, + } + ); +``` + +- [ ] **Step 4: 运行测试** + +```bash +cd WEBUI && npx vitest run "src/app/api/conversations/[threadId]/runs/route.test.ts" +``` + +预期:全部 PASS(含旧的快照复用/绑定用例)。 + +- [ ] **Step 5: Commit** + +```bash +cd WEBUI && git add "src/app/api/conversations/[threadId]/runs/route.ts" "src/app/api/conversations/[threadId]/runs/route.test.ts" +git commit -m "feat(webui): forward generation overrides into run snapshots" +``` + +--- + +### Task 5: WebUI — useAvailableModels 解析生成默认值 + +**Files:** +- Modify: `WEBUI/src/lib/modelRegistry.ts:183-188`(`SelectableModel` 类型) +- Modify: `WEBUI/src/app/hooks/useAvailableModels.ts:38-66`(`parseSelectableModel`,导出以供测试) +- Test: `WEBUI/src/app/hooks/useAvailableModels.test.ts`(新建) + +**Interfaces:** +- Consumes: Task 2 的 `SelectableModel.default_temperature/default_top_p` 响应字段。 +- Produces: `SelectableModel`(TS)新增 `default_temperature: number | null; default_top_p: number | null`;导出 `parseSelectableModel(raw: unknown): SelectableModel | null`。Task 6 消费。 + +- [ ] **Step 1: 写失败测试** + +新建 `WEBUI/src/app/hooks/useAvailableModels.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { parseSelectableModel } from "./useAvailableModels"; + +const RAW = { + model_ref: { provider_id: "zhipu-glm", model_key: "glm-5.2" }, + name: "GLM-5.2", + provider_name: "Zhipu GLM", + effective_capabilities: { + tools: true, + vision: false, + structured_output: true, + }, + default_temperature: 0.7, + default_top_p: 0.95, +}; + +describe("parseSelectableModel", () => { + it("keeps generation defaults from the catalog", () => { + const parsed = parseSelectableModel(RAW); + expect(parsed?.default_temperature).toBe(0.7); + expect(parsed?.default_top_p).toBe(0.95); + }); + + it("defaults missing generation fields to null", () => { + const { default_temperature, default_top_p, ...rest } = RAW; + void default_temperature; + void default_top_p; + const parsed = parseSelectableModel(rest); + expect(parsed?.default_temperature).toBeNull(); + expect(parsed?.default_top_p).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: 运行确认失败** + +```bash +cd WEBUI && npx vitest run src/app/hooks/useAvailableModels.test.ts +``` + +预期:FAIL(`parseSelectableModel` 未导出 / 字段为 undefined)。 + +- [ ] **Step 3: 实现** + +`modelRegistry.ts:183-188`: + +```typescript +export interface SelectableModel { + model_ref: ModelRef; + name: string; + provider_name: string; + effective_capabilities: Capabilities; + default_temperature: number | null; + default_top_p: number | null; +} +``` + +`useAvailableModels.ts`:给 `parseSelectableModel` 加 `export`,函数体 entry 解构与返回值改为: + +```typescript +export function parseSelectableModel(raw: unknown): SelectableModel | null { + if (!raw || typeof raw !== "object") return null; + const entry = raw as { + model_ref?: unknown; + name?: unknown; + provider_name?: unknown; + effective_capabilities?: unknown; + default_temperature?: unknown; + default_top_p?: unknown; + }; + if (!isModelRef(entry.model_ref) || typeof entry.name !== "string") { + return null; + } + const caps = entry.effective_capabilities as + | { tools?: unknown; vision?: unknown; structured_output?: unknown } + | undefined; + return { + model_ref: { + provider_id: entry.model_ref.provider_id, + model_key: entry.model_ref.model_key, + }, + name: entry.name, + provider_name: + typeof entry.provider_name === "string" ? entry.provider_name : "", + effective_capabilities: { + tools: caps?.tools === true, + vision: caps?.vision === true, + structured_output: caps?.structured_output === true, + }, + default_temperature: + typeof entry.default_temperature === "number" + ? entry.default_temperature + : null, + default_top_p: + typeof entry.default_top_p === "number" ? entry.default_top_p : null, + }; +} +``` + +- [ ] **Step 4: 运行测试 + 类型检查** + +```bash +cd WEBUI && npx vitest run src/app/hooks/useAvailableModels.test.ts && npx tsc --noEmit +``` + +预期:PASS;tsc 干净(注意:其他构造 SelectableModel 字面量的位置可能因新必填字段报错——逐一补上 `default_temperature: null, default_top_p: null`)。 + +- [ ] **Step 5: Commit** + +```bash +cd WEBUI && git add src/lib/modelRegistry.ts src/app/hooks/useAvailableModels.ts src/app/hooks/useAvailableModels.test.ts +git commit -m "feat(webui): parse generation defaults from the model catalog" +``` + +--- + +### Task 6: WebUI — 聊天栏内联 temperature/top_p 滑块 + +**Files:** +- Modify: `WEBUI/src/app/components/ChatInterface.tsx:2263-2292`(滑块挂载点)、`2518-2593`(`ReasoningEffortSlider` 之后新增组件) + +**Interfaces:** +- Consumes: Task 3 的 `modelSelection`(含 temperature/top_p)、`setModelSelection`;Task 5 的 `SelectableModel.default_temperature/default_top_p`(经 `selectableModels.find` 按 `modelSelection.primary` 查找)。 +- Produces: 无新导出(页面内组件)。 + +项目无组件测试设施(无 testing-library/jsdom),本任务以 tsc + 全量 vitest + 浏览器手测验证。 + +- [ ] **Step 1: 新增 GenerationParamSlider 组件** + +在 `ChatInterface.tsx` 的 `ReasoningEffortSlider` 之后追加: + +```tsx +function GenerationParamSlider({ + ariaLabel, + disabled, + override, + fallback, + min, + max, + step, + onCommit, +}: { + ariaLabel: string; + disabled: boolean; + /** Current per-thread override; null means the registry default applies. */ + override: number | null; + /** Registry-effective default shown when there is no override. */ + fallback: number | null; + min: number; + max: number; + step: number; + onCommit: (value: number | null) => void | Promise; +}) { + const anchor = override ?? fallback ?? min; + const [draft, setDraft] = useState(null); + const shown = draft ?? anchor; + const isOverride = override !== null || draft !== null; + + const commit = (value: number) => { + setDraft(null); + if (value !== anchor) { + void onCommit(value); + } + }; + + return ( + + setDraft(Number(event.target.value))} + onPointerUp={(event) => + commit(Number((event.target as HTMLInputElement).value)) + } + onKeyUp={(event) => { + if ( + event.key.startsWith("Arrow") || + event.key === "Home" || + event.key === "End" + ) { + commit(Number((event.target as HTMLInputElement).value)); + } + }} + onBlur={(event) => commit(Number(event.target.value))} + className="w-16 cursor-pointer accent-[var(--brand)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed" + /> + + + ); +} +``` + +行为说明:未覆盖时滑块停在注册表生效值、数值为弱化样式;拖动提交覆盖;覆盖后数值加粗,点击数值提交 `null` 重置为默认。 + +- [ ] **Step 2: 挂载到聊天栏** + +`ChatInterface.tsx:2263-2292` 现有 `` 块之后、`{threadId && ( + modelSelection === "inherit" + ? null + : (selectableModels.find( + (m) => + m.model_ref.provider_id === + modelSelection.primary.provider_id && + m.model_ref.model_key === modelSelection.primary.model_key + ) ?? null), + [selectableModels, modelSelection] + ); +``` + +挂载(紧接 `ReasoningEffortSlider` 的 `/>` 之后): + +```tsx + {currentModel && ( + { + if (modelSelection === "inherit") return; + try { + await setModelSelection({ + ...modelSelection, + temperature: value, + }); + toast.success( + value === null + ? "Temperature back to the registry default." + : `Temperature set to ${value.toFixed(2)}.` + ); + } catch (err) { + toast.error( + err instanceof Error + ? `Couldn't update temperature: ${err.message}` + : "Couldn't update temperature — try again." + ); + } + }} + /> + )} + {currentModel && ( + { + if (modelSelection === "inherit") return; + try { + await setModelSelection({ + ...modelSelection, + top_p: value, + }); + toast.success( + value === null + ? "Top P back to the registry default." + : `Top P set to ${value.toFixed(2)}.` + ); + } catch (err) { + toast.error( + err instanceof Error + ? `Couldn't update top P: ${err.message}` + : "Couldn't update top P — try again." + ); + } + }} + /> + )} +``` + +小屏处理:聊天栏容器已有 `flex items-center gap-1.5`;给两个 `GenerationParamSlider` 的最外层 `` 追加 `hidden sm:flex`(替换 `flex items-center`,即 `cn("hidden sm:flex items-center gap-1.5", ...)`),小屏只保留模型名与推理滑块。 + +- [ ] **Step 3: 类型检查 + 全量测试** + +```bash +cd WEBUI && npx tsc --noEmit && npx vitest run +``` + +预期:tsc 干净;全量 vitest PASS。 + +- [ ] **Step 4: 浏览器手测** + +启动 WebUI(端口 4716)与 langgraph dev(6174,需含 Task 1-2 的后端代码,restart 生效)。验证: +1. 选一个模型 → 聊天栏出现 T/P 两个滑块,初始停在注册表默认值、样式弱化。 +2. 拖 T 到 1.20 → toast "Temperature set to 1.20." → 刷新页面后仍是 1.20(元数据持久化)。 +3. 点击数值 → 恢复默认、toast 提示。 +4. 发一条消息 → 后端快照 `request_options` 冻结了覆盖值(可查 `public_snapshot_view` 或日志)。 +5. inherit(未选模型)时三个滑块禁用。 + +- [ ] **Step 5: Commit** + +```bash +cd WEBUI && git add src/app/components/ChatInterface.tsx +git commit -m "feat(webui): inline temperature/top_p sliders in the chat bar" +``` + +--- + +## Self-Review 记录 + +- Spec 覆盖:§1 数据模型 → Task 3;§2 后端快照/resolver → Task 1(+ Task 4 BFF body);§3 UI → Task 5+6(规格中"目录已下发默认值"的假设经核实不成立,Task 2 补齐该下发);§4 错误处理 → Task 1 契约拒绝 + Task 6 toast/回滚(draft 机制)+ CAS 沿用;§5 测试 → 各 Task 内嵌。 +- 占位符:无 TBD;所有代码块为完整可复制实现。Task 4 Step 1 有一处"参考现有用例调整请求构造"的指引——实现者需先读 route.test.ts 首个用例对齐请求形状。 +- 类型一致性:`temperature_override`/`top_p_override`(Python kwargs)→ `SnapshotCreateRequest.temperature/top_p` → BFF body `temperature/top_p` → TS `ThreadModelSelection.temperature/top_p` → `SelectableModel.default_temperature/default_top_p`,全链路命名一致。