Skip to content

Commit c96e7a2

Browse files
committed
fix(webui): re-read the account after a source switch, and stop the key card contradicting itself (P20, UAT4-1/4-2)
1 parent 43d0b2a commit c96e7a2

9 files changed

Lines changed: 470 additions & 36 deletions

File tree

‎docs/webui.md‎

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,7 +1375,7 @@ snapshot is broadcast to every SSE subscriber.
13751375
| Row | Field | Missing-value sentence |
13761376
| --- | --- | --- |
13771377
| 账户名 | `identity.name`, trimmed | the engine answered and reported no name |
1378-
| 当前套餐 | `tokenPlan.tier`, through the Token Plan card's own `planNameOf` | 「未订阅套餐」 when the engine reported no plan; the unread sentence when the surface itself failed |
1378+
| 当前套餐 | `tokenPlan.tier`, through the Token Plan card's own `planNameOf` | 「未订阅套餐」 when the engine reported no plan; 「正在读取当前套餐…」 while a read is in flight; the unread sentence when the surface itself failed |
13791379
| 配额概况 | `tokenPlanQuotaState`, plus `quota.fiveHour` / `quota.weekly` `remainingPercent` | 「引擎未返回读数」 per window; 「不限量」 when the engine reports the window unmetered |
13801380
| 账户状态 | `status` | the unread sentence; a status token with no dictionary entry resolves to no sentence rather than leaking a raw enum |
13811381

@@ -1683,11 +1683,23 @@ return a mask while the engine rejects a mask submitted as a key.
16831683
the `minimax_api` provider and the response says so (`tested:
16841684
"stored_key"`). Two limits are the engine's contract, not this UI's:
16851685
`testUserModel` takes no key override, so an unsaved value cannot be
1686-
probed — the button is disabled with a title saying so while the field
1687-
holds one — and the managed Token Plan credential is not a model-service
1688-
key, so the Token Plan source has nothing to probe here. A probe that
1689-
ran and failed is a completed probe, not an error: it renders the
1690-
engine's status.
1686+
probed — the button is disabled while the field holds one, and a visible
1687+
line under the field says why, because the reason used to live only in a
1688+
`title` attribute that keyboard and touch users never see — and the
1689+
managed Token Plan credential is not a model-service key, so the Token
1690+
Plan source has nothing to probe here. A probe that ran and failed is a
1691+
completed probe, not an error: it renders the engine's status.
1692+
1693+
**The key row is four states, not two.** The badge reads the engine's
1694+
masked projection, but it is describing a field the user can be editing
1695+
right now, so a typed-but-unsaved key is its own state (「已输入,未保存」)
1696+
that outranks both 「已保存密钥」 and 「未启用」 — the user is replacing the
1697+
stored key, or has plainly typed one, and neither badge is true. Symmetric
1698+
to that, the engine's `NO_API_KEY` refusal is a verdict on an EMPTY field:
1699+
typing one falsifies it, so the pinned 「请先填写 API Key」 is dropped on
1700+
the next keystroke. A refusal that is not about the missing key — a
1701+
transport failure, a rejected write — is still true afterwards and stays
1702+
on screen.
16911703

16921704
**Cost.** One extra read per settings-tab open. The read boots the
16931705
engine runtime if none is up, which is the write-side contract and is
@@ -1702,6 +1714,50 @@ engine method behind it. The Token Plan cards stay on decision A1
17021714
(本地版不适用) — wiring them to `/api/usage` and `/api/account` is a
17031715
separate, undecided item, not a side effect of this one.
17041716

1717+
### Usage & models: a source switch re-reads the account (P20)
1718+
1719+
**The defect this fixes.** A UAT round trip on 2026-10-03 (板块 4) switched
1720+
Token Plan → MiniMax API → Token Plan with a key stored in between. The
1721+
Token Plan card came back reading 「未订阅套餐」 while `GET /api/account`
1722+
answered `tier: "Ultra"` throughout, and only F5 recovered it. The
1723+
endpoint was never wrong.
1724+
1725+
**Root cause.** The plan name is read in `UsageModelsSection`
1726+
(`webapp/components/panels.tsx`), mounted only while the port's view is on
1727+
the token-plan tab, so a switch away and back remounts it. The read was
1728+
`useEffect(..., [])` — once per mount — and that mount raced the engine:
1729+
the section rendered in the same tick as `PUT /api/model-source`, and
1730+
`GET /api/account` answers an engine that is still rebinding with HTTP
1731+
**200** and `{ok: false, reason: "no_client"}`. The card read that as "no
1732+
plan", nothing re-read it, and the section's state outlived the failure.
1733+
1734+
**The re-read.** The port owns an `accountRevision` counter and increments
1735+
it after every *successful* source write — the dropdown's `PUT
1736+
/api/model-source` and 保存并使用's `PUT /api/model-source/api-key`, which
1737+
switches the source inside the same engine transaction. The section's
1738+
`/api/account` effect lists that counter as a dependency, so a confirmed
1739+
write re-runs the read against an engine that has finished rebinding. A
1740+
refused write bumps nothing: nothing changed, and a re-read would only
1741+
spend a request to re-render the same answer.
1742+
1743+
**A failed read may not un-know a name.** Revalidation alone is not enough,
1744+
because the re-read can also lose the race. `reconciledAccount`
1745+
(`webapp/components/usage-models-cards.tsx`, pure and unit-tested) keeps
1746+
the last `ok: true` answer standing: only an `ok: true` payload is new
1747+
information, so an unreachable account surface cannot knock a known plan
1748+
off the card. An `ok: true` answer that reports no plan *does* replace it —
1749+
the engine saying "no plan" is an answer, saying "unreachable" is not.
1750+
1751+
**A read in flight is its own sentence.** With the name held, the only
1752+
remaining nameless state is "not read yet", and it renders 「正在读取当前
1753+
套餐…」 rather than 「未订阅套餐」. An account surface that has not
1754+
answered has not said the user has no plan — that conflation was the
1755+
visible half of UAT4-1.
1756+
1757+
**Cost.** One `GET /api/account` per confirmed source write, on a surface
1758+
the user has just acted on. The read is not debounced: a source switch is
1759+
a deliberate act, not a stream of them.
1760+
17051761
### Follow-up messages: the switch is a behaviour (SB-4)
17061762

17071763
**What the user sees.** 跟进消息行为 has three positions. While a task is

‎docs/webui.zh-CN.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,20 +1130,36 @@ Token Plan 视图是桌面的五区块(页签 + 四卡):
11301130

11311131
**密钥行。** 已存的密钥显示为引擎的掩码,绝不显示明文;输入新值即在保存时替换。「保存并使用」是一次请求而不是两次:引擎在一个事务里既写密钥又切来源,因此界面不会出现"密钥已存、来源没切"的中间态。提交空值即**保留**哨兵(`changed: false`,不调用任何引擎写)——与 `PUT /api/providers` 同一约定;它存在的原因是读接口只能返回掩码,而引擎拒绝把掩码当密钥提交。
11321132

1133-
**检测测的是什么、不测什么。** 检测针对 `minimax_api` 供应商上的**已存密钥**,并在响应里说明这一点(`tested: "stored_key"`)。两条限制来自引擎契约而非本界面:`testUserModel` 不接受密钥覆写,因此未保存的值无法被检测(输入框里有未保存内容时按钮禁用并给出原因),而托管的 Token Plan 凭据不是模型服务的密钥,Token Plan 来源在这里没有可检测对象。跑完但失败的检测是一次"跑完的检测"而不是错误,它渲染引擎给出的状态。
1133+
**检测测的是什么、不测什么。** 检测针对 `minimax_api` 供应商上的**已存密钥**,并在响应里说明这一点(`tested: "stored_key"`)。两条限制来自引擎契约而非本界面:`testUserModel` 不接受密钥覆写,因此未保存的值无法被检测——输入框里有未保存内容时按钮禁用,且输入框下方常驻一行**可见**说明(该理由原本只写在 `title` 属性里,键盘与触屏用户根本看不到),而托管的 Token Plan 凭据不是模型服务的密钥,Token Plan 来源在这里没有可检测对象。跑完但失败的检测是一次"跑完的检测"而不是错误,它渲染引擎给出的状态。
1134+
1135+
**密钥行是四态,不是两态。** 徽标读的是引擎的掩码投影,可它描述的是一个用户此刻可能正在编辑的输入框,因此"已填未存"是它自己的一态(「已输入,未保存」),优先级高于「已保存密钥」与「未启用」——用户要么正在替换已存密钥,要么明摆着填了内容,两个徽标此时都不成立。与之对称,引擎的 `NO_API_KEY` 拒绝是针对**空**输入框的判决:打一个字就把它证伪,因此那句钉住的「请先填写 API Key」在下一个字符处消失。与缺密钥无关的拒绝(传输失败、写入被驳回)此后依然成立,继续留在屏幕上。
11341136

11351137
**代价。** 每次打开该设置页多一次读;这次读在没有运行时时会启动引擎,属于写侧契约,在这里可以接受(是用户打开了页签)。若将来把这次读取挪到页面级,必须改用不启动运行时的宿主 getter(见 `server/engine/model-source.js` KNOWN DEBT 2)。
11361138

11371139
**本批没有做的事。** 添加模型弹窗的「自动获取」仍走内置 preset 目录(v2 没有按任意密钥查询供应商目录的方法);Token Plan 四张卡维持 A1 决策的「本地版不适用」——把它们接到 `/api/usage` 与 `/api/account` 是另一个尚未拍板的独立项,不是本批的副作用。
11381140

1141+
**切源后重拉账户(UAT4-1)**
1142+
1143+
**缺陷。** 2026-10-03 的 UAT 第四板块(板块 4)做了一次三来源往返:Token Plan → MiniMax API(存了 Key)→ Token Plan。回来后 Token Plan 卡显示「未订阅套餐」,而 `GET /api/account` 全程答 `tier: "Ultra"`,只有 F5 能恢复。接口自始至终没有说谎。
1144+
1145+
**根因。** 套餐名在 `UsageModelsSection`(`webapp/components/panels.tsx`)里读,而该组件只在端口视图停在 token-plan 页签时挂载,所以切走再切回会重新挂载。那次读是 `useEffect(..., [])`——每个挂载只读一次——而这次挂载与引擎赛跑:组件与 `PUT /api/model-source` 在同一 tick 渲染,此刻仍在重绑定的引擎会让 `GET /api/account` 以 HTTP **200** 加 `{ok: false, reason: "no_client"}` 应答。卡片把它读成"没有套餐",此后无人重读,组件状态比失败活得更久。
1146+
1147+
**重拉机制。** 端口持有一个 `accountRevision` 计数器,在**每一次成功的来源写入之后**递增——下拉的 `PUT /api/model-source`,以及「保存并使用」的 `PUT /api/model-source/api-key`(它在同一个引擎事务里切来源)。`UsageModelsSection` 的 `/api/account` effect 把该计数器列入依赖,于是被确认的写入会对已完成重绑定的引擎重跑这次读。被拒绝的写入不递增:什么都没变,重读只会白花一次请求去渲染同一个答案。
1148+
1149+
**失败的读不能"忘掉"已知的名字。** 只做重拉还不够,因为重拉自己也会输掉赛跑。`reconciledAccount`(`webapp/components/usage-models-cards.tsx`,纯函数、有单测)会让上一个 `ok: true` 的答案继续站住:只有 `ok: true` 的载荷才算新信息,因此账户面不可达无法把已知套餐名从卡上打掉。而 `ok: true` 且明确报"没有套餐"的答案**会**替换它——引擎说"没有套餐"是回答,说"读不到"不是。
1150+
1151+
**读取在途是它自己的一句话。** 名字被保住之后,唯一剩下的无名状态就是"还没读到",它渲染「正在读取当前套餐…」而不是「未订阅套餐」。账户面没应答不等于用户没订阅——把两者混成一句正是 UAT4-1 的可见那一半。
1152+
1153+
**代价。** 每一次被确认的来源写入多一次 `GET /api/account`,发生在用户刚刚操作过的界面上。不做防抖:切源是一次明确动作,不是一串动作。
1154+
11391155
**账户分区(SB-5)**
11401156

11411157
整个分区在挂载时读一次 `GET /api/account`。端点既不新建也不重复:它就是用户菜单账户卡已在读的同一份投影,按需读取的原因也相同——状态快照会广播给每一个 SSE 订阅者,账户数据不进去。
11421158

11431159
| 行 | 字段 | 缺失时的文案 |
11441160
| --- | --- | --- |
11451161
| 账户名 | `identity.name`(去空白) | 引擎应答了但没给名字 |
1146-
| 当前套餐 | `tokenPlan.tier`,走 Token Plan 卡自己的 `planNameOf` | 引擎没报套餐时是「未订阅套餐」;账户面本身不可达时是未读到的那句 |
1162+
| 当前套餐 | `tokenPlan.tier`,走 Token Plan 卡自己的 `planNameOf` | 引擎没报套餐时是「未订阅套餐」;读取在途时是「正在读取当前套餐…」;账户面本身不可达时是未读到的那句 |
11471163
| 配额概况 | `tokenPlanQuotaState`,以及 `quota.fiveHour` / `quota.weekly` 的 `remainingPercent` | 每个窗口各自显示「引擎未返回读数」;窗口被判为不限量时显示「不限量」 |
11481164
| 账户状态 | `status` | 未读到的那句;字典里没有句子的状态枚举归为空,而不是把原始枚举漏给用户 |
11491165

‎packages/webui/webapp/components/panels.tsx‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
PlanCard,
6262
UsageBar,
6363
planNameOf,
64+
reconciledAccount,
6465
resetCaption,
6566
} from "./usage-models-cards";
6667

@@ -3416,6 +3417,7 @@ export function UsageModelsSection({
34163417
autoAddProvider,
34173418
onAutoAddConsumed,
34183419
headless = false,
3420+
accountRevision = 0,
34193421
}: {
34203422
t: (key: MessageKey) => string;
34213423
autoAddProvider?: boolean;
@@ -3425,6 +3427,10 @@ export function UsageModelsSection({
34253427
* three-source switch instead and mounts this headless as its
34263428
* token-plan landing; the header stays for any standalone use. */
34273429
headless?: boolean;
3430+
/** P20 (UAT4-1): bumped by the port after every SUCCESSFUL model-source
3431+
* write, which re-runs the `/api/account` read. A remount alone is not
3432+
* a re-read worth trusting — the remount races the engine's rebind. */
3433+
accountRevision?: number;
34283434
}) {
34293435
// The reference opens on the Token Plan view. The one deliberate
34303436
// exception is the add-provider deep-link: `autoAddProvider` fires the
@@ -3435,26 +3441,41 @@ export function UsageModelsSection({
34353441
);
34363442

34373443
// SB-7 (the A1 revision): the plan NAME is a real figure — /api/account's
3438-
// `tokenPlan.tier`. Read once on mount, the same way the user menu's account
3439-
// card reads it: the route returns the engine's projection and a failed or
3440-
// plan-less answer resolves to null, which the card renders as its honest
3441-
// 「未订阅套餐」 line. A rejected request is likewise a missing name, never a
3444+
// `tokenPlan.tier`. Read on mount, the same way the user menu's account
3445+
// card reads it. A rejected request is likewise a missing name, never a
34423446
// default tier.
3447+
//
3448+
// P20 (UAT4-1): read-once was not enough. Switching the model source
3449+
// remounts this section — the port renders it only for the token-plan
3450+
// tab — so the read fired in the same tick as `PUT /api/model-source`
3451+
// and raced the engine re-binding to the new source. The losing read
3452+
// resolved to `{ok:false}` (HTTP 200, account unreachable), the card
3453+
// printed 「未订阅套餐」, and nothing re-read it until F5. The container
3454+
// therefore bumps `accountRevision` on every successful source write
3455+
// and this read re-runs. `reconciledAccount` keeps the last known-good
3456+
// answer when a revalidation read cannot beat the previous one, so a
3457+
// transient failure can no longer knock the name off the card.
34433458
const [account, setAccount] = useState<api.AccountPayload | null>(null);
3459+
const [accountPending, setAccountPending] = useState(true);
34443460
useEffect(() => {
34453461
let live = true;
3462+
setAccountPending(true);
34463463
void api
34473464
.getAccount()
34483465
.then((payload) => {
3449-
if (live) setAccount(payload);
3466+
if (!live) return;
3467+
setAccount((previous) => reconciledAccount(previous, payload));
34503468
})
34513469
.catch(() => {
3452-
// Unreachable account surface — the placeholder stands in for it.
3470+
// Unreachable account surface — the last known answer stands.
3471+
})
3472+
.finally(() => {
3473+
if (live) setAccountPending(false);
34533474
});
34543475
return () => {
34553476
live = false;
34563477
};
3457-
}, []);
3478+
}, [accountRevision]);
34583479
const planName = planNameOf(account);
34593480

34603481
// The reference's pill: the selected tab sits in a grey rounded pill,
@@ -3514,7 +3535,7 @@ export function UsageModelsSection({
35143535
{view === "tokenPlan" ? (
35153536
<>
35163537
<SectionCard>
3517-
<PlanCard t={t} planName={planName} />
3538+
<PlanCard t={t} planName={planName} planPending={accountPending} />
35183539
</SectionCard>
35193540
<SectionCard>
35203541
<UsageCard t={t} />

0 commit comments

Comments
 (0)