docs(registry): implementation plan for mutually-exclusive sampling_override

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-30 09:48:34 +08:00
parent 862c1e9743
commit 8eff551bb6
@@ -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 <files> -v`;前端测试:`cd WEBUI && npx vitest run <file>`。
- 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<void>;
}) {
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 (
<span
className={cn("flex items-center gap-1.5", disabled && "opacity-50")}
title={
disabled
? "Pick a model to override the registry-configured sampling"
: "Sampling override: temperature and top_p are mutually exclusive"
}
>
{/* ≥sm: segmented control */}
<span className="hidden sm:flex items-center overflow-hidden rounded border border-border">
{modes.map(({ key, label }) => (
<button
key={key}
type="button"
disabled={disabled}
aria-pressed={mode === key}
onClick={() => select(key)}
className={cn(
"px-1.5 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed",
mode === key
? "bg-accent font-medium text-foreground"
: "text-muted-foreground hover:bg-accent/60"
)}
>
{label}
</button>
))}
</span>
{/* <sm: dropdown */}
<select
aria-label="Sampling override"
disabled={disabled}
value={mode}
onChange={(event) =>
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 }) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
{mode === "temperature" && (
<GenerationParamSlider
ariaLabel="Temperature"
disabled={disabled}
override={override?.kind === "temperature" ? override.value : null}
fallback={fallbackTemperature}
min={0}
max={2}
step={0.05}
onCommit={(value) =>
void onCommit(value === null ? null : { kind: "temperature", value })
}
/>
)}
{mode === "top_p" && (
<GenerationParamSlider
ariaLabel="Top P"
disabled={disabled}
override={override?.kind === "top_p" ? override.value : null}
fallback={fallbackTopP}
min={0}
max={1}
step={0.05}
onCommit={(value) =>
void onCommit(value === null ? null : { kind: "top_p", value })
}
/>
)}
</span>
);
}
```
注意:`GenerationParamSlider` 自身带 `hidden sm:flex`,小屏只出下拉(选档即按锚点值提交),不出现滑块——与 spec 的小屏行为一致。组件顶部 import 加 `SamplingOverride` 类型(`@/lib/modelRegistry`)。
- [ ] **Step 3: 替换挂载点**
`ChatInterface.tsx:2307-2376` 两个 `{currentModel && (<GenerationParamSlider .../>)}` 块整体替换为:
```tsx
{currentModel && (
<SamplingOverridePicker
disabled={modelSelection === "inherit"}
override={
modelSelection === "inherit"
? null
: (modelSelection.sampling_override ?? null)
}
fallbackTemperature={
selectedCatalogEntry?.default_temperature ?? null
}
fallbackTopP={selectedCatalogEntry?.default_top_p ?? null}
onCommit={async (value) => {
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(未选模型)时选择器禁用;小屏(<sm)只出下拉。
- [ ] **Step 6: Commit**
```bash
cd WEBUI && git add src/app/components/ChatInterface.tsx
git commit -m "feat(webui): segmented default/temperature/top_p sampling picker in the chat bar"
```
---
## Self-Review 记录
- Spec 覆盖:§1 数据模型 → Task 2(类型/parse/镜像)+ Task 1(schemas);§2 resolver OMIT 语义 + hash → Task 1;§3 BFF/runs → Task 2;§4 UI 分段选择器(含小屏下拉)→ Task 3;§5 目录默认值 → 不动(已上线);§6 错误处理 → Task 1 契约拒绝 + Task 2 parse 400 + Task 3 toast;§7 测试 → 各 Task 内嵌;§8 移除项 → Task 1(后端平铺字段/kwargs/旧 hash)与 Task 2/3(WebUI 平铺字段/旧滑块)。
- 占位符:无 TBD;所有代码块为完整可复制实现。
- 类型一致性:`SamplingOverride`(Python/TS 同名同形)→ `SnapshotCreateRequest.sampling_override` → BFF body `sampling_override` → `ThreadModelSelection.sampling_override` → `compute_selection_hash(primary, reasoning_effort, sampling_override)`,全链路命名一致;hash 常量已按新线协议预计算(`{"primary":null,"reasoning_effort":null,"sampling_override":null}` 的 SHA-256)。
- 整体替换陷阱:`setModelSelection` 为替换语义,Task 3 Step 1 顺带修复 ReasoningEffortSlider 丢字段的既有隐患。