From 484b5a16cc6ba44b0502f267c90762c049c9572c Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 15 Sep 2026 08:54:04 +0800 Subject: [PATCH] fix(ui): identify a market model row by the model, not by its array position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `modelsToView()` used to mint a per-row identity from the row's array index (`id: i`). That index is used as the model's identity in places that outlive the render that produced it: it is persisted (`markRecentUsed()` → `localStorage["atp-recent-models"]`) and resolved again in a *different* array (`D.MARKET` for a guest session) or in a *different catalog order* (`/api/models` is `ORDER BY provider, model`). Because the two spaces overlap numerically, the failure is silent: - live index 5 (`xai/grok-4.6`) → `D.MARKET` id 5 = `google/gemini-3.1-pro` - live index 0 (`anthropic/claude-opus-5`) → the guest table is 1-based, so the entry disappears instead of pointing at something - an admin adds a model that sorts earlier → every index shifts, the chip names `moonshot/kimi-k3`, and the chat opened from that chip is kimi-k3 The identity becomes `modelKey(m)` = `provider/model`, everywhere: the row object no longer carries a position, the three `data-*` carriers (`data-mk-expand` / `data-use-model` / `data-recent-model`) are rendered from `modelKey(m)`, the click sites pass the identity string through unchanged, and the recent-use store accepts identity strings only (a stored *index* cannot be honestly resolved back to a model, so it is dropped once — deliberately). Tests: `src/state_gate.rs::the_model_row_identity_is_the_model_not_its_position` (4 rules, each with its own tooth) plus extractor self-checks; the instruments `tmp/c2138_probe.js` (17 checks) and its A/B legs. --- src/state_gate.rs | 374 ++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 11 ++ ui/index.html | 2 +- ui/js/app.js | 58 ++++--- 4 files changed, 420 insertions(+), 25 deletions(-) diff --git a/src/state_gate.rs b/src/state_gate.rs index d8c2b5b..10cd09a 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -111,6 +111,35 @@ //! 不变量同型);boot 体内的**间接**渲染(调一个自己写的、内部再 `renderView("x")` 的函数) //! 也看不见 —— 射程是「静态调用点」,不是运行期可达性。 +//! # C2138:**位置不是身份** —— 模型行的身份必须是 `provider/model`,不能是数组下标 +//! +//! `modelsToView()` 把 `/api/models` 的行适配成视图行时**自己造过一个身份**:`id: i` —— 数组 +//! 下标。而那个下标会被 `markRecentUsed()` **存进 `localStorage`**(「最近使用」芯片),于是它 +//! 跨了渲染、跨了会话、跨了数组: +//! +//! - **跨数组**:游客兜底表 `data.js > MARKET` 是**另一张表**(7 行、id `1..7`、顺序与长度都 +//! 不同),只是**数字上看起来**是同一个空间 —— 实测:登录态用了 `xai/grok-4.6`(下标 5), +//! 登出进游客市场后芯片写成 `google/gemini-3.1-pro`;下标 0(登录态第一行)在 1-based 的游客 +//! 表里查无此号 ⇒ 芯片**整条消失**。 +//! - **跨渲染**:`/api/models` 是 `ORDER BY provider, model`(`src/dao.rs` 的 `list_models…`)—— +//! 上架/下架/改名任何一个模型,后面所有下标整体位移。实测:管理员加一个排在前面的模型后, +//! 芯片写成 `moonshot/kimi-k3`,而**点开那枚芯片打开的对话也是 kimi-k3** —— 用户以为自己在用 +//! 用过的那个模型(错误从显示变成了动作)。 +//! +//! 四条规则,各有各的牙(A/B 里各自有独立的红集,互不遮蔽): +//! +//! 1. **位置不得进入行对象** —— `modelsToView` 的 `.map(` 回调只许**一个**形参(第二个通常就是 +//! 下标),返回的对象里不得声明字段 `id`; +//! 2. **三处 `data-*` 身份必须由 `modelKey(` 产出**(市场行的展开 / 「使用」、最近使用芯片), +//! 且点击侧必须**原样传递**(不得再用 `Number(` 把身份串转回数字); +//! 3. **`modelKey` 有且只有一处定义**,体内同时提到 `provider` 与 `model`(单靠 model 名会在 +//! 多厂商重名时相撞),且**从不**提到 `id`; +//! 4. **写进「最近使用」的值必须是 `modelKey(...)` 表达式** —— 存储层只接受身份串;旧版本存下来 +//! 的**下标**无法被诚实地还原成某个模型,按空处理、一次性丢弃(刻意的,见 `getRecentKeys`)。 +//! +//! ⚠️ 存储层与显示层都**不许**再按位置解析:`renderRecent` / `openChat` / `consumeModel` 一律 +//! `find((x) => modelKey(x) === key)` —— 规则 2/4 是这两个平面的入口。 +//! use std::collections::{BTreeMap, BTreeSet}; /// 前端源码在**编译期**读入:测试不依赖工作目录与文件系统布局。 @@ -124,6 +153,14 @@ const TX_EVIDENCE: &str = "txTable.loaded"; const DASH_SLOT: &str = "tradeCount"; /// 仪表盘那条「只取 total」的查询(阳性对照:修法不得把它删掉,只许换槽)。 const DASH_TX_QUERY: &str = "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/api/transactions?page=1&page_size=1"; +/// 承载**模型身份**的三处 `data-*`(C2138):渲染侧必须由 `modelKey(` 产出。 +const MODEL_IDENTITY_ATTRS: [&str; 3] = ["data-mk-expand", "data-use-model", "data-recent-model"]; +/// 点击侧读回这三处身份的 `dataset` 名(不得再经 `Number(` 转回位置)。 +const MODEL_IDENTITY_DATASETS: [&str; 3] = [ + "dataset.mkExpand", + "dataset.useModel", + "dataset.recentModel", +]; /// 行首为 `//` 的行:注释行,不参与断言。 fn is_comment_line(line: &str) -> bool { @@ -532,6 +569,108 @@ fn render_view_argument(line: &str) -> Option { Some(after[..end].trim().to_string()) } +/// 这一行是否在**渲染** `attr` 这个 HTML 属性(`data-x="…"`)。 +/// +/// 判别式有两条牙:属性后面跟 `=`(渲染侧写 `data-x=`),且这一行**不是选择器查询** +/// (`querySelector('[data-use-model="' + id + '"]')` 也带 `=`,但它是**读取**,不是产出身份)。 +/// 不区分的话,「谁渲染了身份」会被消费者污染(C2138 A/B 实测:`closest("[data-mk-expand]")` +/// 与 `querySelector('[data-use-model="' + id + '"]')` 都被算成渲染点)。 +fn renders_attr(line: &str, attr: &str) -> bool { + line.contains(&format!("{attr}=")) && !is_selector_query(line) +} + +/// 这一行是否在做**选择器查询**(读 DOM 里的控件,而不是拼 HTML)。 +fn is_selector_query(line: &str) -> bool { + line.contains("querySelector") || line.contains("closest(") || line.contains("getElementById") +} + +/// 这一行是否在**读** `attr` 这个 HTML 属性(`closest("[data-x]")` / `querySelector`)。 +fn reads_attr(line: &str, attr: &str) -> bool { + line.contains(&format!("[{attr}]")) || line.contains(&format!("{attr}]")) +} + +/// 取一个函数的源码。**单行函数**(`function f(m) { return …; }`)只取那一行 —— +/// [`js_function_body`] 按「首个恰为 ` }` 的行」收尾,而单行函数的收尾 `}` 在同一行里, +/// 于是它会一路吞到**下一个**多行函数的收尾(模型身份就是这种单行函数,坑 #319)。 +/// 与兄弟提取器一样,**调用方必须自证**(见 `the_model_identity_extractors_have_teeth`)。 +fn function_source(src: &str, name: &str) -> Option { + let head = format!("function {name}("); + let start = src.find(&head)?; + let line_end = src[start..] + .find('\n') + .map(|i| start + i) + .unwrap_or(src.len()); + let first_line = src[start..line_end].trim_end(); + if first_line.ends_with('}') && first_line.contains('{') { + return Some(first_line.to_string()); + } + js_function_body(src, name).map(str::to_string) +} + +/// 一个 `.map((…) =>` 回调的形参表(已去空白)。`list.map((m) =>` → `["m"]`、`map((m, i) =>` → `["m","i"]`。 +fn map_callback_params(body: &str) -> Vec> { + let mut out = Vec::new(); + let mut from = 0usize; + while let Some(rel) = body[from..].find(".map((") { + let at = from + rel + ".map((".len(); + let end = body[at..].find(')').map(|i| at + i).unwrap_or(body.len()); + out.push( + body[at..end] + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(), + ); + from = at; + if from >= body.len() { + break; + } + } + out +} + +/// `text` 里是否把 `name` 当**标识符**提到(前后不是标识符字符)。`id` 不该被 `valid` 之类绊到。 +fn mentions_identifier(text: &str, name: &str) -> bool { + let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$'; + let bytes = text.as_bytes(); + let mut from = 0usize; + while let Some(rel) = text[from..].find(name) { + let at = from + rel; + let end = at + name.len(); + let before_ok = at == 0 || !is_word(bytes[at - 1] as char); + let after_ok = end >= bytes.len() || !is_word(bytes[end] as char); + if before_ok && after_ok { + return true; + } + from = at + 1; + if from >= text.len() { + break; + } + } + false +} + +/// 这一行是否在**对象字面量里声明了字段** `field`(`id: i,` / `{ id: i }` / `, id: i`)。 +/// +/// 不认 `x.id:` 这类属性访问(前一个非空白字符既不是行首,也不是 `{`/`,`)。 +fn declares_field(line: &str, field: &str) -> bool { + let needle = format!("{field}:"); + let t = line.trim(); + let mut from = 0usize; + while let Some(rel) = t[from..].find(&needle) { + let at = from + rel; + let before = t[..at].trim_end().chars().last(); + if before.is_none() || matches!(before, Some('{') | Some(',')) { + return true; + } + from = at + 1; + if from >= t.len() { + break; + } + } + false +} + #[cfg(test)] mod tests { use super::*; @@ -1202,4 +1341,239 @@ mod tests { "阴性对照失败:没出现过的名字被凭空派生了出来" ); } + + /// 模型行的身份是**模型本身**,不是它在某个数组里的位置(C2138)。四条规则各有各的牙。 + #[test] + fn the_model_row_identity_is_the_model_not_its_position() { + // ── 规则 1:位置不得进入行对象 ────────────────────────────────────────────────── + let row = function_code(APP_JS, "modelsToView"); + assert!( + !row.trim().is_empty(), + "提取器没取到 `modelsToView` 的函数体(后面几条断言会在空集上假绿)" + ); + let params = map_callback_params(&row); + assert_eq!( + params.len(), + 1, + "`modelsToView` 里应当有且只有一个 `.map(` 回调:{params:?}" + ); + assert_eq!( + params[0].len(), + 1, + "`modelsToView` 的 `.map(` 回调声明了 {} 个形参 —— 第二个通常就是数组下标,\ + 而位置不是身份:它会随目录位移、随表换人(C2138):{:?}", + params[0].len(), + params[0] + ); + let id_fields: Vec<&str> = row.lines().filter(|l| declares_field(l, "id")).collect(); + assert!( + id_fields.is_empty(), + "视图行里声明了字段 `id` —— 那是「模型在本数组里的位置」,一旦被存进 \ + localStorage(最近使用)就跨了渲染/会话/数组(C2138):{id_fields:?}" + ); + + // ── 规则 2:三处 `data-*` 身份来自 `modelKey(`,点击侧原样传递 ──────────────────── + let mut rendered = 0usize; + for attr in MODEL_IDENTITY_ATTRS { + let hits: Vec = + lines_owned_by(APP_JS, |l| !is_comment_line(l) && renders_attr(l, attr)) + .into_iter() + .map(|(_, _, l)| l) + .collect(); + assert!( + !hits.is_empty(), + "找不到渲染 `{attr}` 的地方 —— 属性被改名或提取器坏了(空集断言会假绿)" + ); + for h in &hits { + assert!( + h.contains("modelKey("), + "`{attr}` 的值不是由 `modelKey(` 产出的 —— 位置(下标 / `id`)不是身份(C2138):{h}" + ); + } + rendered += hits.len(); + } + assert!( + rendered >= MODEL_IDENTITY_ATTRS.len(), + "承载模型身份的 `data-*` 只找到 {rendered} 处(应 ≥ {}),扫描器没看全", + MODEL_IDENTITY_ATTRS.len() + ); + + let mut consumed = 0usize; + for ds in MODEL_IDENTITY_DATASETS { + let hits: Vec = + lines_owned_by(APP_JS, |l| !is_comment_line(l) && l.contains(ds)) + .into_iter() + .map(|(_, _, l)| l) + .collect(); + assert!( + !hits.is_empty(), + "找不到读 `{ds}` 的点击侧 —— 提取器坏了(空集断言会假绿)" + ); + for h in &hits { + assert!( + !h.contains("Number("), + "`{ds}` 被 `Number(` 转回了数字 —— 身份串又被当成位置用(C2138):{h}" + ); + } + consumed += hits.len(); + } + assert!( + consumed >= MODEL_IDENTITY_DATASETS.len(), + "读模型身份的点击侧只找到 {consumed} 处(应 ≥ {})", + MODEL_IDENTITY_DATASETS.len() + ); + + // ── 规则 3:`modelKey` 只有一处定义,且身份由 provider+model 构成 ──────────────── + let defs = APP_JS.matches("function modelKey(").count(); + assert_eq!( + defs, 1, + "`modelKey` 应当全仓只有一处定义(两份定义会各漂各的):找到 {defs} 处" + ); + let key = function_source(APP_JS, "modelKey").expect("取不到 `modelKey` 的定义"); + assert!( + mentions_identifier(&key, "provider") && mentions_identifier(&key, "model"), + "`modelKey` 必须同时用 `provider` 与 `model` 构成身份 —— 只用 model 名会在多厂商\ + 重名时把两个模型认成同一个:{key}" + ); + assert!( + !mentions_identifier(&key, "id"), + "`modelKey` 体内提到了标识符 `id` —— 位置不得进入身份(C2138):{key}" + ); + + // ── 规则 4:写进「最近使用」的值必须是 `modelKey(...)` 表达式 ──────────────────── + let writes: Vec = lines_owned_by(APP_JS, |l| { + !is_comment_line(l) && l.contains("markRecentUsed(") + }) + .into_iter() + .map(|(_, _, l)| l) + .filter(|l| !l.contains("function markRecentUsed(")) + .collect(); + assert!( + writes.len() >= 2, + "`markRecentUsed(` 的调用点少于 2 处(应有 openChat 与 consumeModel 两个)—— \ + 提取器坏了或调用点被删:{writes:?}" + ); + for h in &writes { + assert!( + h.contains("modelKey("), + "写进「最近使用」的值不是 `modelKey(...)` —— 存下来的位置活不过一次目录变更\ + (C2138):{h}" + ); + } + } + + /// C2138 的提取器与判别式自证:合成输入(含阴性对照)必须让每条牙都能单独咬合。 + #[test] + fn the_model_identity_extractors_have_teeth() { + // `function_source`:单行函数只取那一行(否则会一路吞到下一个多行函数的收尾,坑 #319) + let synthetic = concat!( + " function modelKey(m) { return m.provider + \"/\" + m.model; }\n", + " function modelsToView(list) {\n", + " return list.map((m, i) => {\n", + " return { id: i, provider: m.provider };\n", + " });\n", + " }\n" + ); + let one = function_source(synthetic, "modelKey").expect("取不到单行函数"); + assert!( + !one.contains("modelsToView"), + "`function_source` 把紧随其后的函数吞进来了(判别式会读到别人的 `id`):{one}" + ); + assert!( + mentions_identifier(&one, "provider") && !mentions_identifier(&one, "id"), + "单行函数体读数不对:{one}" + ); + let multi = function_source(synthetic, "modelsToView").expect("取不到多行函数"); + assert!(multi.contains("id: i"), "多行函数体没被取到:{multi}"); + assert!( + !multi.contains("function modelKey"), + "多行函数体取过头了:{multi}" + ); + + // `map_callback_params`:形参个数就是判别式 + assert_eq!( + map_callback_params(&code_only(&multi)), + vec![vec!["m".to_string(), "i".to_string()]], + "形参表读数不对" + ); + assert_eq!( + map_callback_params(" return list.map((m) => m);"), + vec![vec!["m".to_string()]], + "单形参回调被读错了" + ); + assert!( + map_callback_params(" const x = list.map(f);").is_empty(), + "阴性对照失败:没有 `.map((` 的行被读出了形参" + ); + + // `declares_field`:只认对象字面量里的字段声明,不认属性访问 + assert!(declares_field(" id: i,", "id"), "行首字段没被认出"); + assert!( + declares_field(" return { id: i,", "id"), + "`{{` 后字段没被认出" + ); + assert!( + declares_field(" return { a: 1, id: i };", "id"), + "`,` 后字段没被认出" + ); + assert!( + !declares_field(" const x = m.id;", "id"), + "阴性对照失败:属性访问被误判成字段声明" + ); + assert!( + !declares_field(" const idx = 1;", "id"), + "阴性对照失败:`idx` 被误判成 `id`" + ); + assert!( + !declares_field(" api.del(\"/api/admin/models/\" + m.id);", "id"), + "阴性对照失败:URL 里的 `models/\" + m.id` 被误判成字段声明" + ); + + // `renders_attr` / `reads_attr`:渲染侧写 `data-x=`,读取侧写 `[data-x]` —— 判别式是那个 `=` + assert!( + renders_attr( + " '" : null; + const chips = getRecentKeys().map((key) => { + const m = (Live.models ? modelsToView(Live.models) : D.MARKET).find((x) => modelKey(x) === key); + return m ? '" : null; }).filter(Boolean); wrap.innerHTML = chips.join(""); $("#mk-recent").hidden = chips.length === 0; @@ -853,7 +855,7 @@ $("#mk-body").innerHTML = guestHint + (list.length ? list.map((m) => "" + '
' + - '" + + '" + '' + '' + hl(m.provider, rawQ) + "" + '' + hl(m.model, rawQ) + "
" + @@ -866,10 +868,10 @@ // 可用性 pill(rant 第 4 节:keys>=2 可用·N key / keys==1 紧张 / 无计数但标着可用 → 可用 / 无 key) // —— 有计数用计数,没计数用 `avail`(游客兜底表只有布尔,见 availPill 的注释) "" + availPill(m) + "" + - "" + + "" + // 零 mock:成功率后端暂无字段 → 仅当有真实值时展示(multi/success 已从 data.js 移除) (m.success != null ? "
" + T("mk.success", { p: m.success }) + "
" : "") + "" + - (mkExpanded === m.id ? '' + mkDetailHtml(m) + "" : "") + (mkExpanded === modelKey(m) ? '' + mkDetailHtml(m) + "" : "") ).join("") : emptyRow(7, T("mk.empty"), T("mk.empty.sub"), '")); pulseTbody($("#mk-body")); @@ -2990,10 +2992,10 @@ function openChat(id) { // 零 mock(rant 15:54:06):登录态绝不回退 D.MARKET if (loggedIn() && !Live.models) { toast(T("err.loadFail"), "error"); return; } - const m = (Live.models ? modelsToView(Live.models) : D.MARKET).find((x) => x.id === id); + const m = (Live.models ? modelsToView(Live.models) : D.MARKET).find((x) => modelKey(x) === id); if (!m) return; if (!m.avail) { toast(T("chat.busy"), "error"); return; } - markRecentUsed(id); // 记录最近使用(rant 20:46:57 D:去重 + 置顶,最多 5 个) + markRecentUsed(modelKey(m)); // 记录最近使用(rant 20:46:57 D:去重 + 置顶,最多 5 个) renderRecent(); // 立即刷新最近使用 chips chatModel = m; $("#chat-title").textContent = T("chat.title", { model: m.model }); @@ -3015,11 +3017,11 @@ // 零 mock(rant 15:54:06):登录态绝不回退 D.MARKET if (loggedIn() && !Live.models) { toast(T("err.loadFail"), "error"); return; } const src = Live.models ? modelsToView(Live.models) : []; - const m = src.find((x) => x.id === id); + const m = src.find((x) => modelKey(x) === id); if (!m) return; if (!m.avail) { toast(T("chat.busy"), "error"); return; } if (!loggedIn()) { toast(T("chat.login.need"), "error"); return; } - markRecentUsed(id); // 记录最近使用(rant 20:46:57 D) + markRecentUsed(modelKey(m)); // 记录最近使用(rant 20:46:57 D) renderRecent(); const btn = document.querySelector('[data-use-model="' + id + '"]'); if (btn) { btn.disabled = true; btn.textContent = T("chat.calling"); } @@ -3267,13 +3269,21 @@ // multi=available_keys>=2 真实计算;success 后端暂无字段 → null,视图不渲染假成功率; // peak 高峰时段价(rant 2026-08-20T11:58:40):peak_input_per_m>0 → 启用高峰计费,展示 ×N 标注) // 零 mock(rant 2026-08-19T15:54:06):不读 data.js MARKET 兜底 + // 模型行的**稳定身份**(C2138):`provider/model`。**位置不是身份** —— `modelsToView()` 曾用 + // `id: i`(数组下标)当模型的身份,而下标只在生成它的那一次渲染里有意义:`/api/models` 按 + // `provider, model` 排序(加/删一个模型就让后面全部位移),游客兜底表 `data.js > MARKET` 更是 + // 另一张表(7 行、id 1..7、顺序与长度都不同,只是**数字上看起来**是同一个空间)。 + // 一旦下标被存进 localStorage(「最近使用」),它就跨了渲染 / 跨了会话 / 跨了数组 —— 芯片于是 + // 指向**另一个模型**(实测:用了 xai/grok-4.6,游客市场里显示 google/gemini-3.1-pro),或者 + // 因为对不上号而**整条消失**(登录态下标从 0 起,游客表 id 从 1 起)。 + function modelKey(m) { return (m && m.provider ? m.provider : "") + "/" + (m && m.model ? m.model : ""); } + function modelsToView(list) { - return list.map((m, i) => { + return list.map((m) => { const cny = m.currency === "CNY"; const mult = cny ? 1 : 7.2; const peak = (m.peak_input_per_m || 0) > 0; return { - id: i, provider: m.provider, model: m.model, in: Math.round(m.input_per_m * mult * 1e5) / 1e5, @@ -3695,7 +3705,7 @@ // 行展开 / 收起(rant 20:39:30 F:仅展开当前行,点其它行自动收起) const ex = e.target.closest("[data-mk-expand]"); if (ex) { - const id = Number(ex.dataset.mkExpand); + const id = ex.dataset.mkExpand; mkExpanded = mkExpanded === id ? null : id; renderMarketplace(); return; @@ -3703,7 +3713,7 @@ const b = e.target.closest("[data-use-model]"); if (b) { if (isGuest) { toast(T("chat.login.need"), "error"); return; } - consumeModel(Number(b.dataset.useModel)); + consumeModel(b.dataset.useModel); return; } // 空状态:清除筛选 @@ -3718,14 +3728,14 @@ // 最近使用 chips(rant 20:46:57 D:点击直接使用 / 清空) $("#mk-recent").addEventListener("click", (e) => { if (e.target.closest("[data-mk-recent-clear]")) { - saveRecentIds([]); + saveRecentKeys([]); renderRecent(); return; } const c = e.target.closest("[data-recent-model]"); if (c) { if (isGuest) { toast(T("chat.login.need"), "error"); return; } - openChat(Number(c.dataset.recentModel)); + openChat(c.dataset.recentModel); } }); $("#chat-send").addEventListener("click", sendChat);