From 900a0e86aae3ab128914ff530f8ca0e97f322096 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sat, 10 Oct 2026 14:57:49 +0800 Subject: [PATCH 01/19] Add Session Trajectory Mini App --- README.md | 1 + README.zh-CN.md | 1 + .../.minimax-plugin/plugin.json | 14 + plugins/avatasia/mmc-trajectory/LICENSE | 21 + plugins/avatasia/mmc-trajectory/README.md | 98 + .../avatasia/mmc-trajectory/README.zh-CN.md | 98 + plugins/avatasia/mmc-trajectory/icon.png | Bin 0 -> 1574 bytes .../mmc-trajectory/miniapp/client/index.html | 3095 +++++++++++++++++ .../mmc-trajectory/miniapp/miniapp.json | 20 + .../miniapp/node/miniapp-api.ts | 109 + .../mmc-trajectory/miniapp/node/server.mjs | 1949 +++++++++++ plugins/avatasia/mmc-trajectory/package.json | 6 + 12 files changed, 5412 insertions(+) create mode 100644 plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json create mode 100644 plugins/avatasia/mmc-trajectory/LICENSE create mode 100644 plugins/avatasia/mmc-trajectory/README.md create mode 100644 plugins/avatasia/mmc-trajectory/README.zh-CN.md create mode 100644 plugins/avatasia/mmc-trajectory/icon.png create mode 100644 plugins/avatasia/mmc-trajectory/miniapp/client/index.html create mode 100644 plugins/avatasia/mmc-trajectory/miniapp/miniapp.json create mode 100644 plugins/avatasia/mmc-trajectory/miniapp/node/miniapp-api.ts create mode 100644 plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs create mode 100644 plugins/avatasia/mmc-trajectory/package.json diff --git a/README.md b/README.md index 20eaf54..78f92a6 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) | | [Self-drive Route Planner](plugins/hanzijie/self-drive-route-planner/) | **Official plugin** for planning driving routes with place search, route alternatives, demo mode, and Xiaohongshu 3:4 itinerary cards | [HanZijie](https://github.com/HanZijie) | | [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [Microbiosis](https://github.com/Microbiosis) | +| [Session Trajectory](plugins/avatasia/mmc-trajectory/) | Browse a conversation's model trajectory: messages, reasoning, tool calls and results, Token usage, and per-turn timing | [avatasia](https://github.com/avatasia) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index e18ec61..085edb0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,6 +65,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) | | [自驾规划](plugins/hanzijie/self-drive-route-planner/README.zh-CN.md) | 【官方插件】规划自驾路线、地点搜索、候选算路与小红书 3:4 行程图;支持演示模式 | [HanZijie](https://github.com/HanZijie) | | [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [Microbiosis](https://github.com/Microbiosis) | +| [会话轨迹](plugins/avatasia/mmc-trajectory/README.zh-CN.md) | 按轮次浏览一段会话的模型轨迹:消息流、思考过程、工具调用与结果、token 用量与每轮耗时 | [avatasia](https://github.com/avatasia) |
预览:Token 用量看板 diff --git a/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json b/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json new file mode 100644 index 0000000..18f9edd --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "mmc-trajectory", + "displayName": "会话轨迹", + "version": "1.0.0", + "description": "按轮次浏览当前会话的模型轨迹:消息流、思考过程、工具调用与结果、token 与耗时统计。", + "author": "avatasia", + "icon": "icon.png", + "category": "Other", + "exampleQueries": ["打开会话轨迹"], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/avatasia/mmc-trajectory/LICENSE b/plugins/avatasia/mmc-trajectory/LICENSE new file mode 100644 index 0000000..6b00177 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 avatasia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/avatasia/mmc-trajectory/README.md b/plugins/avatasia/mmc-trajectory/README.md new file mode 100644 index 0000000..48da75f --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/README.md @@ -0,0 +1,98 @@ +# Session Trajectory + +English | [简体中文](README.zh-CN.md) + +Browse the model trajectory of a MiniMax Code conversation: messages, reasoning, tool calls and their results, Token usage, and per-turn timing. + +Author: [avatasia](https://github.com/avatasia) · Version: `1.0.0` + +## Install and use + +Copy this entire directory into your active MiniMax Code data directory, including the hidden `.minimax-plugin` directory. By default, `` is the `.minimax` directory in your home folder (`~/.minimax`), so plugins go in `~/.minimax/plugins/`. If you have configured a different data directory, use that directory instead: + +```text +/plugins/mmc-trajectory/.minimax-plugin/plugin.json +``` + +The author directory is only used to group contributions in the community repository; the installed path does not include it. Restart a MiniMax Code version that supports MiniApps, confirm the plugin is recognized and enabled, and open "会话轨迹" (Session Trajectory) from the `@` menu, or ask the Agent to open it. + +The page follows the most recent conversation by default and re-reads it every 2.5 seconds, so it stays live while you work; it pauses while the page is hidden. Each read is validated with an HTTP `ETag`: when the session file has not grown, the service answers `304 Not Modified` with no body and the page keeps what it already rendered. An idle tab therefore costs a few bytes per poll instead of the whole trajectory. Use the session picker to switch to any other conversation. No API key and no dependency installation are required — the package is dependency-free and needs no build step. + +## What it shows + +The ledger groups records by turn, following the `turn_id` recorded in the session file. Each record is classified as user, assistant, thinking, tool call, tool result, or system, and each turn header carries that turn's own Token counts and elapsed time. Selecting a record opens a detail panel with summary, raw JSON, tool arguments, tool result, and reasoning, plus a button that hands the record to the Agent's chat input. + +Values that the session file does not record are rendered as `—`. Nothing is inferred or filled in: a tool call with no matching result has no duration, an assistant message carries no model attribution if the file omitted it, and a message with no Token usage shows no Token usage. + +An assistant message is always displayed in one fixed order — **reasoning, then the answer, then the tool calls it asked for** — regardless of the order the blocks appear in the session file. Token usage stays with the answer; a message that produced reasoning but no answer shows its usage on the reasoning block, because that is all the message contains. + +One model call produces one row per block, so a rail down the left edge joins the rows that arrived together — a response split into reasoning, answer and tool calls reads as one unit. A response that produced a single row shows a dot instead. The grouping is also stated in each row's tooltip, so it is never conveyed by position alone. Tool results are not model responses, carry no rail, and are not joined to the call that caused them. + +When a session has been compacted, the runtime does not trim it in place: it rotates the previous `messages.jsonl` into `snapshots/`, opens a new generation, and starts a fresh active file. **The ledger still shows the whole conversation.** The app walks the generation chain the same way the runtime does — it opens the active file, reads the generation that file declares, and steps back one generation at a time through the parent each file names — and then concatenates every reachable generation oldest-first. A snapshot whose parent generation is not exactly one lower ends the chain rather than being stitched on, and a snapshot the chain never reaches (a fork leaves those behind) is reported as an orphan instead of being shown. + +A **上下文代** (context generation) picker appears once a session has more than one generation, so the ledger can be narrowed to any single slice; it defaults to **全部(默认)**, the whole lineage. Narrowing to an older generation says plainly that it is a frozen snapshot rather than live history, and the message tile then shows the session-wide total next to the slice. + +## Data & access + +**Files read.** The service resolves the session root in this order: the `MAVIS_HOME` environment variable, then `.minimax` in the home directory, giving `/v2/sessions`. An `MMC_TRAJECTORY_ROOT` environment variable overrides the whole path for testing. From each session directory it reads `messages.jsonl` (the active generation), `manifest.json` (the session id and creation time), and `history-catalog.json` (the generation list, plus a committed byte length per artifact). Each snapshot reachable from the active generation is then read from `snapshots/`, bounded by the length the catalog recorded, and only the first 64 KB of each candidate is needed to decide whether it belongs to the chain at all. It also opens `/v2/sqlite/runtime-state.sqlite` **read-only** to resolve which conversation is active and to read real session titles. + +The catalog length bounds the read so a file that is being appended to is never parsed mid-line; any unparsable line is skipped and counted. The session directory name is `base64url(sessionId)`, so a session id can be recovered from the filename alone; the service cross-checks that against `manifest.json` and the database before reading any file. + +Snapshot file names come out of `history-catalog.json`, which is data rather than code, so a name is only used when it is a plain basename with no path separator and no `..`, and the resolved path is then required to still sit inside the session directory. Both checks have to pass before anything is opened. + +**Files written.** None. The runtime never writes to disk. + +**Network.** None. The app makes no outbound requests, has no telemetry, and needs no credential configuration. + +**Processes spawned.** None. + +**State.** Cache state is kept in process memory only and is discarded on exit. Nothing is persisted between runs. + +Session titles and message text are your real local data — take care when sharing screenshots or your screen. + +Three details of the session format are worth knowing, because they change what you see: + +- Host-injected blocks such as `` are recorded with role `user` but are not user prompts. The service splits them off using the recorded `canonicalTextRange` and shows them as `系统` (system) records, so the ledger does not present injected context as something you typed. +- A `user` message whose text is only an injected block has no prompt, so sub-agent and background-task sessions legitimately show no user record. +- A `compactionSummary` message is the runtime's own context checkpoint — not something you or the model said. It is labelled `压缩` (compaction) rather than `系统`, holds no Token usage of its own, and reports the context size it replaced, so a long session shows where its earlier context went. Its detail panel also names the generation it opened, who produced it, and the revision it replaced. + +## Diagnostics + +The Node runtime also serves `GET /api/runtime`, a diagnostic route reporting what the process can observe about its own session identity: the Node version, the count and names of environment variables, whether any environment value or argv entry has the shape of a session id, and whether the runtime database could be opened and what it resolved to. It returns names and structure only — filesystem locations are reduced to a bare filename, and no absolute path appears in the response. + +The Host assigns the listening port at startup and logs it as `miniapp.runtime.listening`. This route is the reason the claims in the next section were established rather than assumed, and it is the first thing to check when a build of MiniMax Code changes how the runtime is spawned. On the verified build it reports 13 environment variable names, with no environment value and no argv entry matching a session id. + +## How the active session is chosen + +MiniMax Code does not tell a Mini App which conversation opened its page: the runtime context passed to `start(context)` has no session id, the Host bridge exposed to the page provides only `miniapp.message.append`, and the page URL carries no parameter. This app therefore infers the conversation and **always states how it decided**, in the page footer. + +The runtime database is asked first. Among conversations (`session_kind = 'conversation'` with no `purpose`, not archived), it prefers one holding a live turn lease in `local_runtime_session_locks`, then one with status `started`, then the most recently updated. Cron runs and background worker tasks are excluded by `session_kind`. + +If no conversation is running a turn, or the database cannot be read, the app falls back to the most recently written session file. If two or more conversations hold live turn leases at the same time, the app reports the ambiguity, names every candidate, and asks you to confirm instead of silently picking one. + +The runtime database is opened in read-only mode. If it is missing, unreadable, or its schema no longer matches, the app degrades to the file-based heuristic rather than failing. + +## Limits and known gaps + +- The app depends on undocumented internal formats: the `v2/sessions` directory layout and the runtime database schema. A client update can change either, which may break session identification or parsing. +- Session identification is an inference, not a binding. With one conversation active it is reliable; with none running it degrades to most-recently-written. +- A single `messages.jsonl` is read up to 64 MB. Larger sessions are truncated and the page says so. +- The ledger renders 200 records at a time with an explicit "load earlier" control rather than true virtual scrolling. +- `messageCount` and `turnCount` in the session picker are estimates derived from a bounded prefix of the file; exact values come from the selected session. +- Light theme token coverage is verified — every `--mcode-*` token the page consumes is defined for both themes — but its rendered appearance was not visually checked; the page was exercised under a dark system preference. + +## Tested environment + +- **MiniMax Code:** 3.1.1.178 (desktop, Windows build) +- **Operating system:** Windows 10.0.26200 (x64) +- **Node in the Mini App runtime:** v24.18.0 + +Verified manually in the client: publishing and startup, live polling during an active turn, session switching, type filtering, record inspection, and error states. A 19.6 MB session loads without truncation. Session resolution was cross-checked against the conversation title shown in the client and against the session's own manifest. + +**Not verified:** macOS and Linux, and the light theme's rendered appearance. Compatibility with unofficial or source builds is also unverified. + +Source layout: the page is `miniapp/client/index.html` and the Node service is `miniapp/node/server.mjs`. No build step and no third-party dependency is required; `miniapp/node/miniapp-api.ts` is a type declaration only and is never imported at runtime. + +## License + +[MIT](LICENSE). \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/README.zh-CN.md b/plugins/avatasia/mmc-trajectory/README.zh-CN.md new file mode 100644 index 0000000..b159050 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/README.zh-CN.md @@ -0,0 +1,98 @@ +# 会话轨迹 + +[English](README.md) | 简体中文 + +按轮次浏览一段 MiniMax Code 对话的模型轨迹:消息流、思考过程、工具调用与结果、token 消耗与每轮耗时。 + +作者:[avatasia](https://github.com/avatasia) · 版本:`1.0.0` + +## 安装与使用 + +把整个目录(含隐藏的 `.minimax-plugin`)复制到你的 MiniMax Code 活跃数据目录下。默认情况下 `` 是家目录下的 `.minimax`(即 `~/.minimax`),所以插件放在 `~/.minimax/plugins/`。如果你配置过其它数据目录,请用那个目录: + +```text +/plugins/mmc-trajectory/.minimax-plugin/plugin.json +``` + +作者目录只用于社区仓库里归类贡献,安装路径不包含它。重启一个支持 MiniApp 的 MiniMax Code 版本,确认插件已被识别并启用,从 `@` 菜单打开「会话轨迹」,或直接让 Agent 帮你打开。 + +页面默认跟随最近一段对话,每 2.5 秒重新读取一次,因此你工作时它会保持实时更新;页面不可见时会自动暂停。每次读取都通过 HTTP `ETag` 做校验:当会话文件没有增长时,服务端返回 `304 Not Modified` 且不带响应体,页面保留已渲染的内容 —— 因此空闲页签每次轮询只花几个字节,而不是整份轨迹。用会话选择器可以切换到其它任何对话。不需要 API key,也不需要装依赖 —— 本包零依赖、无构建步骤。 + +## 它展示什么 + +台账按 `turn_id` 把记录分组,轮次划分完全依据会话文件中记录的 `turn_id`。每条记录被归类为用户、助手、思考、工具调用、工具结果或系统;每个轮次表头带该轮自己的 token 计数与耗时。选中任意记录会打开详情面板,含摘要、原始 JSON、工具入参、工具结果与思考内容,并提供一个把该记录内容送回 Agent 对话输入框的按钮。 + +会话文件没有记录的值一律渲染为 `—`,不做任何推断或补全:没有匹配结果的工具调用就没有耗时,会话文件没写模型信息的消息就不带模型归属,没有 token 统计的消息就不显示 token。 + +一条 assistant 消息始终按固定顺序展示 —— **思考、然后回答、然后它发起的工具调用** —— 与这些块在会话文件里的排列顺序无关。token 用量跟着回答走;只产生思考、没有回答的消息,用量显示在思考记录上,因为那就是该消息的全部内容。 + +一次模型调用会拆成若干行,左侧的 tree 线把同一次返回的行连在一起 —— 一条被拆成思考、回答、工具调用的响应因此读起来是一个整体;只产生单行的响应则显示为一个圆点。同样的信息也写在每行的悬浮提示里,不会只靠位置表达。工具结果不是模型响应,没有连线,也不与引发它的那次调用相连。 + +会话被压缩之后,运行时并不是就地裁剪:它把原来的 `messages.jsonl` 轮转到 `snapshots/`,开启新一代上下文,再新建一份活跃文件。**台账仍然展示完整对话。** 应用用与运行时相同的方式走这条代链 —— 打开活跃文件、读出它自己声明的代号、再沿着每一代各自指名的父代一代一代往回走 —— 然后把每一代按从旧到新的顺序拼接起来。若某一代声明的父代不是恰好小一,代链就在此中断、不会把它接上;代链从未走到的快照(fork 会留下这种)会被当作孤立代报告,而不是展示出来。 + +当一个会话存在多代时,工具栏会出现「上下文代」选择器,用于把台账收窄到任意单独一代;其默认值是**全部(默认)**,也就是完整代链。收窄到旧代时会明确说明它是冻结的历史快照而不是实时记录,此时「消息」会并列显示全程条数。 + +## 数据与访问 + +**读取的文件。** 服务按以下顺序解析会话根目录:环境变量 `MAVIS_HOME`,其次是家目录下的 `.minimax`,最终得到 `/v2/sessions`。环境变量 `MMC_TRAJECTORY_ROOT` 可以整体覆盖该路径,供测试使用。每个会话目录下读取 `messages.jsonl`(活跃那一代)、`manifest.json`(会话 id 与创建时间)、`history-catalog.json`(上下文代清单,以及每个 artifact 已提交的字节长度)。从活跃代出发能走到的每一份快照,随后会按清单记录的长度为界从 `snapshots/` 读取;而判断某个候选是否属于这条代链,只需要读它的前 64 KB。另外**只读**打开 `/v2/sqlite/runtime-state.sqlite`,用于判定哪段对话正在运行并读取真实会话标题。 + +目录清单里的长度用来给读取范围设上界,避免正在被追加写入的文件被解析到半行;无法解析的行会被跳过并计数。会话目录名是 `base64url(sessionId)`,因此单凭文件名就能还原会话 id;服务在读取任何文件之前,会拿它与 `manifest.json`、数据库三方交叉校验。 + +快照文件名来自 `history-catalog.json`,那是数据而不是代码。因此一个文件名只有在「是不含路径分隔符与 `..` 的纯 basename」且「解析后的路径仍位于会话目录之内」这两条同时成立时才会被使用;两道检查都通过之后才会打开文件。 + +**写入的文件。** 无。运行时不写磁盘。 + +**网络。** 无。不发起任何出站请求,无遥测,不需要配置任何凭证。 + +**启动的子进程。** 无。 + +**状态。** 缓存只保留在进程内存中,退出即丢弃,运行之间不落盘任何东西。 + +会话标题与消息正文是你的真实本地数据 —— 分享截图或投屏时请留意。 + +有三处会话格式的细节值得知道,因为它们会直接改变你看到的内容: + +- Host 注入的块(例如 ``)在文件里记为 `user` 角色,但它不是你输入的提示词。服务依据文件中记录的 `canonicalTextRange` 把它们切出来,作为 `系统` 记录展示,因此台账不会把注入的上下文伪装成你说过的话。 +- 文本只有一个注入块的 `user` 消息其实没有提示词,所以 sub-agent 与后台任务的会话里看不到用户记录是正常现象,不是缺陷。 +- `compactionSummary` 消息是运行时自己打的上下文检查点,既不是你说的也不是模型说的。它标为 `压缩` 而不是 `系统`,自身不带 token 用量,并透出它替换掉的那份上下文的规模 —— 于是长会话能看出早先的上下文去了哪里。它的详情页还会说明这一检查点开启了第几代、由谁生成、以及替换掉的是哪一个版本。 + +## 诊断 + +Node 运行时另外提供一个诊断路由 `GET /api/runtime`,报告该进程能观测到的自身会话身份信息:Node 版本、环境变量的数量与名称、是否有环境变量值或 argv 条目形似会话 id、以及运行时数据库能否打开、最终解析出了什么。它只返回名称与结构 —— 文件系统位置一律压缩成纯文件名,响应里不含任何绝对路径。 + +监听端口由 Host 在启动时分配,并以 `miniapp.runtime.listening` 记录日志。这个路由正是下一节那些结论能被「坐实」而不是「假设」的原因;当 MiniMax Code 的某个版本改变了运行时的拉起方式时,它也是第一个该查的地方。在已验证的版本上,它报告 13 个环境变量名,其中没有任何环境变量值、也没有任何 argv 条目能匹配上会话 id。 + +## 当前会话如何确定 + +MiniMax Code 不会告诉 Mini App 是哪段对话打开了它的页面:传给 `start(context)` 的运行时上下文里没有 session id,暴露给页面的 Host 桥接只有 `miniapp.message.append`,页面 URL 也不带任何参数。因此本应用采用推断,**并且始终在页脚说明它是怎么判断的**。 + +优先向运行时数据库询问。在所有对话(`session_kind = 'conversation'`、无 `purpose`、未归档)中,依次优先选择:在 `local_runtime_session_locks` 中持有活跃 turn 租约的、状态为 `started` 的、最后更新的那个。cron 任务与后台 worker 任务会被 `session_kind` 排除掉。 + +如果没有任何对话正在跑轮次,或数据库读不出来,应用会退回到最近写入的会话文件。如果同时有两段以上对话持有活跃 turn 租约,应用会明确报告这个歧义、列出全部候选,请你确认,而不是悄悄挑一个。 + +运行时数据库以只读模式打开。如果文件缺失、不可读,或表结构已经对不上,应用会降级为基于文件的启发式判断,而不是直接失败。 + +## 限制与已知缺口 + +- 本应用依赖未公开的内部格式:`v2/sessions` 目录结构与运行时数据库表结构。客户端一次更新就可能改动其中之一,导致会话识别或解析失效。 +- 会话识别是推断,不是绑定。只有一段对话在跑时它很可靠;没有对话在跑时会降级为「最近写入」。 +- 单个 `messages.jsonl` 最多读取 64 MB。更大的会话会被截断,页面会明确说明。 +- 台账每次渲染 200 条记录,配一个显式的「加载更早」控件,不是真正的虚拟滚动。 +- 会话选择器里的 `messageCount` / `turnCount` 是从文件的有界前缀估算的;精确值以选中会话后展示的为准。 +- 浅色主题的 token 覆盖已验证(页面用到的每个 `--mcode-*` 变量在两套主题下都有定义),但渲染外观未做视觉检查;功能验收是在深色系统偏好下进行的。 + +## 测试环境 + +- **MiniMax Code:** 3.1.1.178(桌面版,Windows 构建) +- **操作系统:** Windows 10.0.26200(x64) +- **Mini App 运行时 Node:** v24.18.0 + +在客户端内手动验证过:发布与启动、活跃轮次期间的实时轮询、会话切换、类型筛选、记录检视、错误态展示。一个 19.6 MB 的会话可无截断加载。会话解析结果与客户端里显示的对话标题、以及会话自身的 manifest 做过交叉核对。 + +**未验证:** macOS 与 Linux,以及浅色主题的渲染外观;与官方发行版之外的或源码构建版本的兼容性同样未验证。 + +源码位置:页面是 `miniapp/client/index.html`,Node 服务是 `miniapp/node/server.mjs`。不需要构建步骤,不需要任何第三方依赖;`miniapp/node/miniapp-api.ts` 只是类型声明,运行时不会被导入。 + +## 许可证 + +[MIT](LICENSE)。 diff --git a/plugins/avatasia/mmc-trajectory/icon.png b/plugins/avatasia/mmc-trajectory/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..299a26cbf5c1427dffa9cd575cf721307d570765 GIT binary patch literal 1574 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV7=|>;uumf=gr;N9Fb5F_7BDH zN*t40jx3m@A#jD`h{82~A;W#9hIKB=6V;QM-fp_!Iw3Du_^7z>7spdwD|5UT=(0U- z4vcs42{>&0A!&-l#UFng7Vv9zUfN`Qd~;|s@7+uz9O)JMQ;7Q z+VlNc*1f)P;(=w;neIK@5%Dq?bm#T^{r{9*@ia!gnj^~T2}4Q1*Th_Vu`>}GpIDCO z#4fN@Vt4rd)BClU(Q~fW#@QT3`o97jTK?xMOo;EM)AkEGZ>^la!>4}Tg|#orV!ID- z4G8cmi29Kul*I@Nov(+hUWIHbj*XAkN|#&6e?ev6^ZSxvcK?~yFA_-*H<;a0#(2g^ zzl72KdH~l2CW&?ibaY*)m{GcGsq4HCVQW8`+3MMAA9>%$$6x1gcEXjIh6=|UTHH*2 zPm^w)+0I})`*&FN5&Nu1Y+r6yc*L8(Sa9m$yrfKZ#~fa1&NHPdC8Y+RdyG?WF};29 zt~O0_E62oyX%mAzpJu)CiI9%EV`HT-k0aLM2a{@p^XrK&uP*%0esO=1w)UU=+zCGMvNmy= z|6P>ouW5@p2v)su`cxCfV?$ymt*7pfdX*%ncOdCuPzej4`!;F|NW(R z>9S1?D}6X`#NYh7GVkf}bgjz@`%TThovQS5e3ma}8>P8_foSNz|MS*u5I(ouy{)ZH zDAVlt_qYF?TCQcTsM{ODK6ko9)iJH7$x{Rh??u)A%=k6?)3WE!yQWM!akgaN_W!DX z-#&jmHN3X`^}*WR`-q(SpO3zY`t01fRdCqjHPcc83_B1GbPU2zNIsfpf)(T$7XRi5-O#h75#2n`^6HJMF!qC&lGw+h(&G*h? zk*k_rMVov+YqaeSTOYYZzjSt0F6+COd#ca&eqZ;_rtH&`lb%gyI!#lfHFBb5N}kls z$lZDCNY^>py$dH>z2A9YX;J*2GNpMejVH=I&vYJ{xB2|{X>r$mYq=dibJoiERAfK) zQ9W)K_T5@<--_8kB%7Wkzk0OuD!*pi4Mquhsb5FS-xaZWR zZ|9tq^y-Rf{wEFBD;u8QSW~|%f9dnxxlsofN1ZNZ>HPit!>su-YkBth3z|N4bh|s- zYxS08(KkMRy%ipDKxc+#k7En7+RrklI~Sdmo1bg6`PiN0n#CTy2vIV@izrM+Ju?Ht m|NpbhtN4MXJsYT)XJ&Z(ZtAoneFio_5e83JKbLh*2~7YK=FPVN literal 0 HcmV?d00001 diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html new file mode 100644 index 0000000..8da2b48 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -0,0 +1,3095 @@ + + + + + + 会话轨迹 + + + + +
+ + +

+ + + + + + + +
+

关键指标

+
+
+ +
+
+
+

轨迹

+ +
+ +
+
+ +
+
+

检视

+ +
+

尚未选择记录

+
+
+
+
+
+ + +
+

+
+
+ +
+ + + +
+
+ + + + diff --git a/plugins/avatasia/mmc-trajectory/miniapp/miniapp.json b/plugins/avatasia/mmc-trajectory/miniapp/miniapp.json new file mode 100644 index 0000000..498d880 --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/miniapp-api.ts b/plugins/avatasia/mmc-trajectory/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..7a0e0fd --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/miniapp-api.ts @@ -0,0 +1,109 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs new file mode 100644 index 0000000..9c789fc --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -0,0 +1,1949 @@ +// @ts-check + +import { open, readdir, readFile, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { basename, join, resolve, sep } from 'node:path'; +import { homedir } from 'node:os'; +import { createHash } from 'node:crypto'; + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +const MAX_WALK_DEPTH = 4; +const MAX_SESSION_CANDIDATES = 400; +const MAX_SESSION_LIST = 30; +const MAX_MESSAGE_BYTES = 64 * 1024 * 1024; +/** + * Tools whose reply arrives through the tool rather than as a chat message. A turn that calls + * one of these is followed, sometimes after a compaction sits in between, by a turn that has + * no user message because the reader's answer never became one. + */ +const USER_INPUT_TOOLS = new Set(['ask_user', 'request_feature_enable']); +// Enough of a file to read its opening line. A generation marker lives on the first row, so +// deciding whether a snapshot belongs to the lineage never needs more than this. +const GENERATION_PROBE_BYTES = 64 * 1024; +const LIST_PEEK_BYTES = 256 * 1024; +const MAX_FIELD_CHARS = 20000; +const MAX_SUMMARY_CHARS = 160; +const MAX_ARGUMENT_BYTES = 32000; +const MAX_LABEL_CHARS = 60; +const MAX_RAW_BYTES = 65536; +const LIST_CACHE_TTL_MS = 1000; +const SESSION_CACHE_TTL_MS = 5000; +const SESSION_CACHE_ENTRIES = 80; +const TRAJECTORY_CACHE_ENTRIES = 4; +const SESSION_STORE_TOKEN = ''; +const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }; +const ROLE_PATTERN = /"role"\s*:\s*"(user|assistant|toolResult|custom)"/g; +const TURN_PATTERN = /"turn_id"\s*:\s*"([^"]*)"/g; +const BLOCK_PATTERN = /"type"\s*:\s*"(toolCall|thinking|text)"/g; +const ABSOLUTE_STORE_PATTERN = /(?:[A-Za-z]:[\\/])?[^\s"'`<>|]{0,120}?[\\/]?\.minimax(?:[\\/][^\s"'`<>|]{0,160})?/g; + +/** + * Resolve the local session store. Never returns a location that is logged or returned to the browser. + * @param {Record} [env] + * @returns {{ root: string, sessionsRoot: string }} + */ +export function resolveSessionsRoot(env = process.env) { + const candidates = [env.MMC_TRAJECTORY_ROOT, env.MAVIS_HOME]; + let home = ''; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + home = candidate.trim(); + break; + } + } + if (!home) home = join(homedir(), '.minimax'); + return { root: home, sessionsRoot: join(home, 'v2', 'sessions') }; +} + +/** + * Versioned `.history-mutation-*` copies also contain `messages.jsonl`; only real session folders count. + * @param {string} name + */ +export function isSessionDirName(name) { + return name.includes('-session_') && !name.startsWith('.'); +} + +/** + * @param {unknown} raw + * @param {number} fallback + * @param {number} min + * @param {number} max + */ +export function normalizeLimit(raw, fallback, min, max) { + const parsed = Number.parseInt(typeof raw === 'string' ? raw : '', 10); + if (!Number.isFinite(parsed)) return fallback; + if (parsed < min) return min; + if (parsed > max) return max; + return parsed; +} + +/** + * @param {string} value + * @param {number} max + */ +export function singleLine(value, max) { + const flattened = String(value ?? '').replace(/\s+/g, ' ').trim(); + return flattened.length > max ? flattened.slice(0, max) : flattened; +} + +/** + * @param {unknown} value + * @param {number} max + * @returns {{ text: string | null, truncated: boolean }} + */ +export function clipField(value, max) { + if (typeof value !== 'string' || value === '') return { text: null, truncated: false }; + if (value.length <= max) return { text: value, truncated: false }; + return { text: value.slice(0, max), truncated: true }; +} + +/** + * Replaces internal store locations so the browser never learns where sessions live. + * @param {readonly string[]} secrets + */ +export function createRedactor(secrets) { + const prefixes = [...new Set(secrets.filter((entry) => typeof entry === 'string' && entry.length > 0))] + .sort((a, b) => b.length - a.length); + if (prefixes.length === 0) return (value) => value; + return (value) => { + let out = String(value); + for (const prefix of prefixes) out = out.split(prefix).join(SESSION_STORE_TOKEN); + return out.includes('.minimax') ? out.replace(ABSOLUTE_STORE_PATTERN, SESSION_STORE_TOKEN) : out; + }; +} + +/** + * @param {string} text + * @returns {{ rows: Array>, skipped: number }} + */ +export function parseMessagesJsonl(text) { + /** @type {Array>} */ + const rows = []; + let skipped = 0; + let start = 0; + while (start <= text.length) { + let end = text.indexOf('\n', start); + if (end === -1) end = text.length; + const line = text.slice(start, end); + start = end + 1; + if (line.trim() === '') { + if (end >= text.length) break; + continue; + } + try { + const parsed = JSON.parse(line); + if (parsed && typeof parsed === 'object') rows.push(parsed); + else skipped += 1; + } catch { + skipped += 1; + } + if (end >= text.length) break; + } + return { rows, skipped }; +} + +/** + * Cheap single-pass counters used by the session list; values are approximate by design. + * @param {string} text + */ +export function countRolesInPrefix(text) { + const turnIds = new Set(); + const counts = { messages: 0, userMessages: 0, assistantMessages: 0, toolResults: 0, systemMessages: 0, toolCalls: 0, thinkingBlocks: 0, turns: 0 }; + for (const match of text.matchAll(/"message_id"\s*:/g)) counts.messages += 1; + for (const match of text.matchAll(ROLE_PATTERN)) { + if (match[1] === 'user') counts.userMessages += 1; + else if (match[1] === 'assistant') counts.assistantMessages += 1; + else if (match[1] === 'toolResult') counts.toolResults += 1; + else counts.systemMessages += 1; + } + for (const match of text.matchAll(TURN_PATTERN)) turnIds.add(match[1]); + for (const match of text.matchAll(BLOCK_PATTERN)) { + if (match[1] === 'toolCall') counts.toolCalls += 1; + else if (match[1] === 'thinking') counts.thinkingBlocks += 1; + } + counts.turns = turnIds.size; + return counts; +} + +/** + * @param {string} dirName + */ +export function decodeSessionIdFromDirName(dirName) { + const marker = '-session_'; + const index = dirName.indexOf(marker); + if (index < 0) return null; + const decoded = Buffer.from(dirName.slice(index + marker.length), 'base64url').toString('utf8'); + return /^[A-Za-z0-9_-]{4,128}$/.test(decoded) ? decoded : null; +} + +/** + * The active catalog length is the only trustworthy byte boundary while the file is being appended. + * @param {unknown} catalog + * @returns {number | null} + */ +export function activeCatalogBytes(catalog) { + if (!catalog || typeof catalog !== 'object') return null; + const artifacts = /** @type {{ artifacts?: unknown }} */ (catalog).artifacts; + if (!Array.isArray(artifacts)) return null; + let total = 0; + let found = false; + for (const entry of artifacts) { + if (!entry || typeof entry !== 'object') continue; + const artifact = /** @type {{ kind?: unknown, fileName?: unknown, byteLength?: unknown }} */ (entry); + if (artifact.kind !== 'active' || artifact.fileName !== 'messages.jsonl') continue; + const size = Number(artifact.byteLength); + if (Number.isFinite(size) && size >= 0) { + total = Math.max(total, Math.floor(size)); + found = true; + } + } + return found ? total : null; +} + +/** + * @param {string} filePath + * @param {number} limit + * @returns {Promise<{ text: string, size: number, read: number }>} + */ +export async function readHead(filePath, limit) { + const handle = await open(filePath, 'r'); + try { + const stat = await handle.stat(); + const size = stat.size; + const wanted = Math.max(0, Math.min(limit, size)); + const buffer = Buffer.allocUnsafe(wanted); + let filled = 0; + while (filled < wanted) { + const { bytesRead } = await handle.read(buffer, filled, wanted - filled, filled); + if (bytesRead <= 0) break; + filled += bytesRead; + } + const view = buffer.subarray(0, filled); + const lastNewline = view.lastIndexOf(0x0a); + const usable = lastNewline === -1 ? filled : lastNewline; + return { text: view.subarray(0, usable).toString('utf8'), size, read: filled }; + } finally { + await handle.close(); + } +} + +/** + * @param {string} dir + */ +async function readJsonIfPresent(dir, name) { + try { + const raw = await readFile(join(dir, name), 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +/** + * A basename we are willing to join onto a directory. The catalog is data on disk, and data + * does not get to choose which file gets opened, so anything carrying a separator, a dot + * segment or an unexpected extension is dropped rather than normalised. + */ +const SAFE_ARTIFACT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}\.jsonl$/; +const SAFE_REVISION = /^sha256:[0-9a-f]{64}$/; + +/** + * Which generation a row came from, carried on the row itself while a stitched lineage is + * being built. A symbol keeps it off `JSON.stringify` and out of every raw payload. + */ +export const ROW_GENERATION = Symbol('rowGeneration'); + +/** + * A finite, non-negative JSON number — or nothing. + * + * `Number()` is deliberately not used here: it turns `null` into 0 and `"7"` into 7, so a + * malformed catalog would come back as a confident-looking zero-byte generation rather than + * as a dropped entry. + * @param {unknown} value + */ +function nonNegativeInteger(value) { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null; +} + +/** + * Every context generation the runtime has recorded for a session. + * + * A compaction does not trim history in place: the runtime rotates the previous + * `messages.jsonl` into `snapshots/.jsonl` and starts a fresh active file, so after a + * compaction the active file holds only what came *after* the checkpoint. `artifacts[]` is + * the only place the earlier generations are still listed, so without this a compacted + * session silently reads as if all of its earlier messages never existed. + * + * @param {unknown} catalog + * @returns {Array<{ generation: number, kind: string, fileName: string, byteLength: number | null, messageCount: number | null, revision: string | null, active: boolean }>} + */ +export function parseCatalogGenerations(catalog) { + if (!catalog || typeof catalog !== 'object') return []; + const artifacts = /** @type {{ artifacts?: unknown }} */ (catalog).artifacts; + if (!Array.isArray(artifacts)) return []; + /** @type {Map} */ + const byGeneration = new Map(); + for (const entry of artifacts) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const artifact = /** @type {Record} */ (entry); + const generation = nonNegativeInteger(artifact.generation); + if (generation === null) continue; + const fileName = typeof artifact.fileName === 'string' ? artifact.fileName : ''; + if (!SAFE_ARTIFACT_NAME.test(fileName) || fileName.includes('..')) continue; + const kind = typeof artifact.kind === 'string' && artifact.kind.trim() ? artifact.kind.trim() : 'snapshot'; + const revision = typeof artifact.revision === 'string' && SAFE_REVISION.test(artifact.revision) ? artifact.revision : null; + // First entry wins: a duplicated generation is a malformed catalog, and picking the + // first one keeps the answer stable instead of depending on array order luck. + if (byGeneration.has(generation)) continue; + byGeneration.set(generation, { + generation, + kind, + fileName, + byteLength: nonNegativeInteger(artifact.byteLength), + messageCount: nonNegativeInteger(artifact.messageCount), + revision, + active: kind === 'active', + }); + } + return [...byGeneration.values()].sort((a, b) => a.generation - b.generation); +} + +/** + * @param {unknown} catalog + * @param {Array<{ generation: number, kind: string, fileName: string, byteLength: number | null, messageCount: number | null, revision: string | null, active: boolean }>} generations + * @returns {number | null} + */ +export function activeCatalogGeneration(catalog, generations) { + const declared = catalog && typeof catalog === 'object' + ? nonNegativeInteger(/** @type {{ activeGeneration?: unknown }} */ (catalog).activeGeneration) + : null; + if (declared !== null && generations.some((entry) => entry.generation === declared)) return declared; + const actives = generations.filter((entry) => entry.active); + if (actives.length > 0) return actives[actives.length - 1].generation; + return generations.length > 0 ? generations[generations.length - 1].generation : null; +} + +/** + * Resolves a catalog artifact to a readable file inside the session directory. + * + * Two independent checks stand between the catalog and `open()`: the name must match the + * safe-basename pattern, and the resolved path must still sit under the session directory. + * Either one alone would be enough to catch a hand-edited catalog; both are cheap, and this + * is the only place in the app that opens a file named by something other than the app. + * + * @param {string} dir + * @param {{ generation: number, kind: string, fileName: string, byteLength: number | null, messageCount: number | null, revision: string | null, active: boolean }} artifact + * @returns {string | null} + */ +export function resolveArtifactPath(dir, artifact) { + if (!artifact || typeof artifact.fileName !== 'string') return null; + if (!SAFE_ARTIFACT_NAME.test(artifact.fileName) || artifact.fileName.includes('..')) return null; + const root = resolve(dir); + const candidate = resolve(root, artifact.active ? artifact.fileName : join('snapshots', artifact.fileName)); + if (candidate !== root && !candidate.startsWith(root + sep)) return null; + return candidate; +} + +/** + * The generation marker a file opens with. + * + * A rotation is only believable because the file that opens it says so: the first row of + * every generation after the first is a `compactionSummary` carrying `history_artifact`, + * and that object names the generation it opened and the exact revision it replaced. The + * runtime reads the same marker to walk its lineage, and so does this app — a filename or + * a catalog entry is a claim, this is the receipt. + * + * @param {string} headText + * @returns {{ generation: number, parentGeneration: number | null, parentCompactionId: string | null, parentRevision: string | null } | null} + */ +export function readGenerationMarker(headText) { + const firstLine = headText.split('\n', 1)[0]; + if (!firstLine) return null; + /** @type {any} */ + let row; + try { + row = JSON.parse(firstLine); + } catch { + return null; + } + if (!row || typeof row !== 'object') return null; + const message = row.message; + if (!message || typeof message !== 'object' || message.role !== 'compactionSummary') return null; + const artifact = row.history_artifact; + if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) return null; + const generation = nonNegativeInteger(artifact.generation); + if (generation === null || generation === 0) return null; + const parent = artifact.parentSnapshot && typeof artifact.parentSnapshot === 'object' && !Array.isArray(artifact.parentSnapshot) + ? /** @type {Record} */ (artifact.parentSnapshot) + : null; + return { + generation, + parentGeneration: parent ? nonNegativeInteger(parent.generation) : null, + parentCompactionId: parent && typeof parent.compactionId === 'string' ? parent.compactionId : null, + parentRevision: parent && typeof parent.revision === 'string' && SAFE_REVISION.test(parent.revision) + ? parent.revision + : null, + }; +} + +/** + * Walks a session's generation chain the way the runtime does: start at the active file, + * take the generation it declares, then step back one generation at a time through the + * parent each file names. + * + * The catalog is used only to find candidate files. Every candidate still has to open and + * declare the generation it was reached for, so a catalog that lists a file belonging to a + * different lineage cannot smuggle it in, and a file whose parent generation is not exactly + * one lower ends the chain instead of being stitched on. Snapshots never reached are orphans + * — a fork leaves them behind — and are reported rather than shown. + * + * @param {{ generations: Array<{ generation: number, kind: string, fileName: string, byteLength: number | null, messageCount: number | null, revision: string | null, active: boolean }> }} catalog + * @param {(generation: number | null) => Promise<{ fileName: string, marker: { generation: number, parentGeneration: number | null } | null } | null>} probe `null` asks for the active file + */ +export async function walkLineage(catalog, probe) { + const byGeneration = new Map(); + for (const entry of catalog.generations) { + if (!entry.active && !byGeneration.has(entry.generation)) byGeneration.set(entry.generation, entry); + } + + const activeProbe = await probe(null); + if (activeProbe === null) return { chain: [], orphans: [] }; + + const activeGeneration = activeProbe.marker ? activeProbe.marker.generation : 0; + const chain = [{ generation: activeGeneration, fileName: activeProbe.fileName }]; + const reached = new Set([activeGeneration]); + + let expect = activeGeneration; + while (expect > 0) { + const want = expect - 1; + if (!byGeneration.has(want)) break; + const parent = await probe(want); + if (parent === null) break; + const declared = parent.marker ? parent.marker.generation : 0; + if (declared !== want) break; + if (parent.marker && parent.marker.parentGeneration !== null && parent.marker.parentGeneration !== want - 1) break; + if (reached.has(declared)) break; + reached.add(declared); + chain.unshift({ generation: declared, fileName: parent.fileName }); + expect = want; + } + + const orphans = catalog.generations + .filter((entry) => !entry.active && !reached.has(entry.generation)) + .map((entry) => ({ generation: entry.generation, fileName: entry.fileName })); + return { chain, orphans }; +} + +/** + * @param {string} dir + */ +export async function readSessionIdentity(dir) { + const manifest = await readJsonIfPresent(dir, 'manifest.json'); + const catalog = await readJsonIfPresent(dir, 'history-catalog.json'); + const manifestId = manifest && typeof manifest.sessionId === 'string' ? manifest.sessionId.trim() : ''; + const id = manifestId || decodeSessionIdFromDirName(basename(dir)) || basename(dir); + const createdAtMs = manifest && Number.isFinite(Number(manifest.createdAtMs)) ? Number(manifest.createdAtMs) : null; + const generations = parseCatalogGenerations(catalog); + return { + id, + createdAtMs, + catalogBytes: activeCatalogBytes(catalog), + generations, + activeGeneration: activeCatalogGeneration(catalog, generations), + }; +} + +/** + * Depth-capped walk that only descends into dated folders and never into a session folder twice. + * @param {string} sessionsRoot + */ +export async function collectSessionEntries(sessionsRoot) { + /** @type {Array<{ dir: string, sizeBytes: number, lastActiveAt: number }>} */ + const entries = []; + /** @param {string} dir @param {number} depth */ + const walk = async (dir, depth) => { + if (depth > MAX_WALK_DEPTH || entries.length >= MAX_SESSION_CANDIDATES) return; + let names; + try { + names = await readdir(dir); + } catch { + return; + } + for (const name of names) { + if (entries.length >= MAX_SESSION_CANDIDATES) return; + const child = join(dir, name); + if (isSessionDirName(name)) { + try { + const fileStat = await stat(join(child, 'messages.jsonl')); + entries.push({ dir: child, sizeBytes: fileStat.size, lastActiveAt: Math.floor(fileStat.mtimeMs) }); + } catch { + // A session folder without a readable messages file is simply not a candidate. + } + continue; + } + if (name.startsWith('.')) continue; + await walk(child, depth + 1); + } + }; + await walk(sessionsRoot, 0); + entries.sort((a, b) => b.lastActiveAt - a.lastActiveAt); + return entries.slice(0, MAX_SESSION_LIST); +} + +/** + * @param {unknown} value + * @returns {number | null} + */ +function finiteOrNull(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +/** + * The runtime database is the only place on this machine that knows which conversation is + * live. It is optional: a missing file, a missing `node:sqlite`, or any query failure must + * degrade to the filesystem heuristic rather than break the board. + * @param {string} home + */ +async function openRuntimeStateDb(home) { + try { + const { DatabaseSync } = await import('node:sqlite'); + const dbPath = join(home, 'v2', 'sqlite', 'runtime-state.sqlite'); + if (!existsSync(dbPath)) return null; + return new DatabaseSync(dbPath, { readOnly: true }); + } catch { + return null; + } +} + +/** @param {unknown} value */ +function toNumber(value) { + if (typeof value === 'bigint') return Number(value); + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** + * Resolve the conversation the user is actually in. + * + * The Host never tells the page which session opened it, so we ask the runtime database + * instead. Ranking, strongest signal first: + * 1. holds a live turn lease — an agent turn is running right now + * 2. status = 'started' — open and not finished + * 3. most recently updated — the fallback for a long-idle conversation + * + * Only real user conversations qualify. `session_kind = 'conversation'` with a NULL + * `purpose` excludes cron runs (`purpose = 'cron:...'`) and background worker/explore + * tasks (`purpose = 'local-background-task:bg_...'`), which are exactly the sessions that + * would otherwise win on file mtime while the user is looking at a different one. + * + * @param {string} home + * @returns {Promise<{ sessionId: string, title: string | null, relativeDir: string | null, status: string | null, leased: boolean } | null>} + */ +export async function resolveCurrentConversation(home) { + const db = await openRuntimeStateDb(home); + if (!db) return null; + const now = Date.now(); + const QUALIFY = "s.session_kind = 'conversation' and s.purpose is null and ifnull(s.archived, 0) = 0"; + try { + const row = db.prepare(` + select s.session_id as session_id, s.title as title, s.status as status, + s.history_relative_dir as history_relative_dir, + case when exists ( + select 1 from local_runtime_session_locks l + where l.session_id = s.session_id and l.expires_at_ms > ? + ) then 1 else 0 end as leased + from local_runtime_sessions s + where ${QUALIFY} + order by leased desc, + case when s.status = 'started' then 0 else 1 end asc, + s.updated_at_ms desc + limit 1 + `).get(now); + if (!row || typeof row.session_id !== 'string') return null; + + // More than one conversation holding a live lease means we genuinely cannot tell which + // one this page belongs to. Report it instead of silently picking by a stale timestamp. + const leasedRows = db.prepare(` + select s.session_id as session_id, s.title as title + from local_runtime_sessions s + where ${QUALIFY} + and exists (select 1 from local_runtime_session_locks l + where l.session_id = s.session_id and l.expires_at_ms > ?) + order by s.updated_at_ms desc + limit 5 + `).all(now); + const candidates = leasedRows + .filter((entry) => entry && typeof entry.session_id === 'string') + .map((entry) => ({ + sessionId: entry.session_id, + title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : null, + })); + + return { + sessionId: row.session_id, + title: typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null, + relativeDir: typeof row.history_relative_dir === 'string' && row.history_relative_dir.trim() + ? row.history_relative_dir.trim() + : null, + status: typeof row.status === 'string' ? row.status : null, + leased: toNumber(row.leased) === 1, + ambiguous: candidates.length > 1, + candidates, + }; + } catch { + return null; + } +} + +/** + * Titles for the session picker. Missing database simply means the caller keeps its + * filesystem-derived label. + * @param {string} home + * @param {readonly string[]} sessionIds + * @returns {Promise>} + */ +export async function readSessionTitles(home, sessionIds) { + const result = new Map(); + const db = await openRuntimeStateDb(home); + if (!db || sessionIds.length === 0) return result; + try { + const stmt = db.prepare('select session_id, title, session_kind from local_runtime_sessions where session_id = ?'); + for (const id of sessionIds) { + const row = stmt.get(id); + if (!row) continue; + result.set(id, { + title: typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null, + kind: typeof row.session_kind === 'string' ? row.session_kind : null, + }); + } + } catch { + // A title is a nicety; never let it fail the list. + } + return result; +} + +function emptyTokens() { + return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; +} + +/** + * @param {unknown} usage + * @returns {{ input: number, output: number, cacheRead: number, cacheWrite: number, totalTokens: number | null } | null} + */ +export function normalizeUsage(usage) { + if (!usage || typeof usage !== 'object' || Array.isArray(usage)) return null; + const source = /** @type {Record} */ (usage); + const input = finiteOrNull(source.input); + if (input === null && finiteOrNull(source.output) === null) return null; + return { + input: input ?? 0, + output: finiteOrNull(source.output) ?? 0, + cacheRead: finiteOrNull(source.cacheRead) ?? 0, + cacheWrite: finiteOrNull(source.cacheWrite) ?? 0, + totalTokens: finiteOrNull(source.totalTokens), + }; +} + +/** + * @param {unknown} block + * @returns {string | null} + */ +function blockText(block) { + if (!block || typeof block !== 'object') return typeof block === 'string' ? block : null; + const source = /** @type {Record} */ (block); + if (typeof source.text === 'string') return source.text; + return null; +} + +/** + * @param {unknown} content + * @returns {string | null} + */ +export function contentToPlainText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return null; + const parts = []; + for (const block of content) { + const text = blockText(block); + if (text !== null) parts.push(text); + } + return parts.length > 0 ? parts.join('\n') : null; +} + +/** + * Host-injected blocks are recorded with role "user" but are not user prompts. + * They must not be shown as if the user typed them. + */ +const INJECTED_PREFIX = /^\s*(?:<(?:system-reminder|async-audit|background-task-finished|media-output-reminder|mcode-tools-master-reminder|task-completion-reminder|environment_details)\b|\[runaway guard\])/; + +/** + * `canonicalTextRange` marks where the real prompt starts inside a message whose + * content is prefixed by injected blocks. Checked before the injection test because + * a real prompt is itself prefixed by ``. + * @param {Record} message + * @returns {{ start: number, end: number } | null} + */ +export function canonicalPromptRange(message) { + const range = message.canonicalTextRange; + if (!range || typeof range !== 'object' || Array.isArray(range)) return null; + const start = Number(/** @type {any} */ (range).startOffset); + if (!Number.isFinite(start) || start < 0) return null; + const rawEnd = Number(/** @type {any} */ (range).endOffset); + const end = Number.isFinite(rawEnd) && rawEnd > start ? rawEnd : null; + return { start, end: end ?? start }; +} + +/** + * Splits a "user" row into the injected prefix and the real prompt. + * @param {Record} message + * @param {string | null} plain + * @returns {{ prompt: string | null, injected: string | null }} + */ +export function splitUserMessage(message, plain) { + if (plain === null || plain.trim() === '') return { prompt: null, injected: null }; + const range = canonicalPromptRange(message); + if (range) { + const prompt = plain.slice(range.start, range.end).trim(); + const injected = plain.slice(0, range.start).trim(); + if (prompt) return { prompt, injected: injected || null }; + if (injected) return { prompt: null, injected }; + } + if (INJECTED_PREFIX.test(plain)) return { prompt: null, injected: plain }; + return { prompt: plain.trim(), injected: null }; +} + +/** + * @param {unknown} args + */ +function compactValue(value, max) { + if (value === null) return 'null'; + if (typeof value === 'string') return singleLine(value, max); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `[${ + value.slice(0, 3).map((entry) => compactValue(entry, 24)).join(', ') + }${value.length > 3 ? ', …' : ''}]`; + if (typeof value === 'object') return '{…}'; + return 'null'; +} + +/** + * @param {string} name + * @param {unknown} args + */ +export function toolCallSummary(name, args) { + if (!args || typeof args !== 'object' || Array.isArray(args)) return `${name}{}`; + const entries = Object.entries(/** @type {Record} */ (args)).slice(0, 4); + if (entries.length === 0) return `${name}{}`; + const rendered = entries.map(([key, value]) => `${key}="${compactValue(value, 48)}"`); + return `${name}{${rendered.join(', ')}}`; +} + +/** + * Builds the frozen `/api/trajectory` payload from already-parsed rows. + * @param {{ session: { id: string, createdAtMs?: number | null }, rows: Array>, sizeBytes?: number, truncated?: boolean, redact?: (value: string) => string, generations?: Array>, generation?: number | null, orphans?: Array<{ generation: number, fileName: string }> }} input + */ +export function buildTrajectoryPayload(input) { + const { session, rows, sizeBytes = 0, truncated = false } = input; + const redact = input.redact ?? ((value) => value); + /** @type {Array>} */ + const records = []; + /** @type {Array>} */ + const turns = []; + const turnById = new Map(); + const toolCallsById = new Map(); + const stats = { + messages: rows.length, + userMessages: 0, + assistantMessages: 0, + toolCalls: 0, + toolResults: 0, + thinkingBlocks: 0, + injectedBlocks: 0, + compactions: 0, + turns: 0, + turnsWithPrompt: 0, + qaTurns: 0, + compactionTurns: 0, + turnsWithoutPrompt: 0, + errors: 0, + tokens: emptyTokens(), + durationMs: null, + }; + + let startedAt = null; + let lastTimestamp = null; + let label = null; + let textTruncatedAny = false; + + /** @param {string | null} turnId */ + const ensureTurn = (turnId) => { + const key = turnId ?? ''; + let turn = turnById.get(key); + if (!turn) { + turn = { + id: turnId, + index: turns.length + 1, + startedAt: null, + endedAt: null, + durationMs: null, + toolCalls: 0, + errors: 0, + tokens: emptyTokens(), + records: [], + }; + turnById.set(key, turn); + turns.push(turn); + } + return turn; + }; + + for (const row of rows) { + const message = row && typeof row.message === 'object' && row.message !== null ? row.message : {}; + const source = /** @type {Record} */ (message); + const role = typeof source.role === 'string' ? source.role : 'unknown'; + const timestamp = finiteOrNull(source.timestamp); + const turnId = typeof row.turn_id === 'string' ? row.turn_id : null; + const turn = ensureTurn(turnId); + if (timestamp !== null) { + if (startedAt === null || timestamp < startedAt) startedAt = timestamp; + if (lastTimestamp === null || timestamp > lastTimestamp) lastTimestamp = timestamp; + if (turn.startedAt === null || timestamp < turn.startedAt) turn.startedAt = timestamp; + if (turn.endedAt === null || timestamp > turn.endedAt) turn.endedAt = timestamp; + } + + const raw = buildRawPayload(source, redact); + const usage = normalizeUsage(source.usage); + if (usage) { + stats.tokens.input += usage.input; + stats.tokens.output += usage.output; + stats.tokens.cacheRead += usage.cacheRead; + stats.tokens.cacheWrite += usage.cacheWrite; + if (usage.totalTokens !== null) stats.tokens.totalTokens += usage.totalTokens; + turn.tokens.input += usage.input; + turn.tokens.output += usage.output; + turn.tokens.cacheRead += usage.cacheRead; + turn.tokens.cacheWrite += usage.cacheWrite; + if (usage.totalTokens !== null) turn.tokens.totalTokens += usage.totalTokens; + } + if (source.isError === true) { + stats.errors += 1; + turn.errors += 1; + } + + const messageId = typeof row.message_id === 'string' ? row.message_id : null; + const base = { + id: messageId, + turnId, + role, + // Stamped when a lineage is stitched, so the page can group or filter by generation + // without having to re-derive it from timestamps. + generation: nonNegativeInteger(/** @type {any} */ (row)[ROW_GENERATION]), + timestamp, + // One model call = one responseId, and every row split out of that message repeats it, + // so the page can draw which rows arrived together. Tool results are not model + // responses and carry none. + responseId: typeof source.responseId === 'string' ? source.responseId : null, + relativeMs: null, + durationMs: null, + toolName: null, + toolCallId: null, + tokens: null, + model: typeof source.model === 'string' ? source.model : null, + provider: typeof source.provider === 'string' ? source.provider : null, + api: typeof source.api === 'string' ? source.api : null, + stopReason: typeof source.stopReason === 'string' ? source.stopReason : null, + isError: source.isError === true ? true : source.isError === false ? false : null, + raw: raw.value, + rawOmitted: raw.omitted, + textTruncated: false, + }; + + /** @param {Partial> & { kind: string, title: string, summary: string }} patch */ + const push = (patch) => { + const record = { + ...base, + index: records.length + 1, + relativeMs: timestamp === null || startedAt === null ? null : timestamp - startedAt, + text: null, + thinking: null, + arguments: null, + result: null, + error: null, + tokensBefore: null, + producedBy: null, + parentGeneration: null, + parentCompactionId: null, + parentRevision: null, + ...patch, + }; + if (record.textTruncated) textTruncatedAny = true; + records.push(record); + turn.records.push(record); + return record; + }; + + const content = source.content; + const blocks = Array.isArray(content) ? content : null; + + if (role === 'assistant') { + stats.assistantMessages += 1; + const texts = []; + const thinkingParts = []; + const callParts = []; + if (blocks) { + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + const item = /** @type {Record} */ (block); + if (item.type === 'text' && typeof item.text === 'string') texts.push(item.text); + else if (item.type === 'thinking' && typeof item.thinking === 'string') thinkingParts.push(item.thinking); + else if (item.type === 'toolCall') callParts.push(item); + } + } else if (typeof content === 'string') texts.push(content); + else if (typeof source.text === 'string') texts.push(source.text); + + const textSource = texts.length > 0 ? texts.join('\n') : null; + const textField = clipField(textSource === null ? null : redact(textSource), MAX_FIELD_CHARS); + const thinkingField = clipField(thinkingParts.length > 0 ? redact(thinkingParts.join('\n')) : null, MAX_FIELD_CHARS); + + let usageAttached = false; + const carrierUsage = () => { + if (usageAttached) return null; + usageAttached = true; + return usage; + }; + + // The ledger imposes one explicit order on an assistant message: the reasoning, then + // the answer, then the actions it asked for. The session writer emits them in this order + // today, but the page must not depend on that — an ordering this UI presents is an + // invariant we own, so state it outright rather than inferring it from array position. + /** @type {Array<'text' | 'thinking' | 'toolCall'>} */ + const segmentOrder = []; + const present = (kind) => ( + kind === 'text' ? textField.text !== null + : kind === 'thinking' ? thinkingField.text !== null + : callParts.length > 0 + ); + for (const kind of /** @type {const} */ (['thinking', 'text', 'toolCall'])) { + if (present(kind)) segmentOrder.push(kind); + } + + // Token usage belongs to the assistant message, and must follow the answer it was + // measured for — not the reasoning block that now precedes it. A message with no text + // keeps the usage on its first record, which is the rule that applied before. + const usageOwner = textField.text !== null ? 'text' : (segmentOrder[0] ?? null); + + for (const kind of segmentOrder) { + if (kind === 'text') { + if (textField.text === null) continue; + push({ + kind: 'assistant', + title: 'assistant', + summary: singleLine(textField.text, MAX_SUMMARY_CHARS), + text: textField.text, + textTruncated: textField.truncated, + tokens: kind === usageOwner ? carrierUsage() : undefined, + }); + continue; + } + if (kind === 'thinking') { + if (thinkingField.text === null) continue; + stats.thinkingBlocks += 1; + push({ + kind: 'thinking', + title: 'thinking', + summary: singleLine(thinkingField.text, MAX_SUMMARY_CHARS), + thinking: thinkingField.text, + textTruncated: thinkingField.truncated, + tokens: kind === usageOwner ? carrierUsage() : undefined, + }); + continue; + } + for (const call of callParts) { + stats.toolCalls += 1; + turn.toolCalls += 1; + const name = typeof call.name === 'string' ? call.name : 'unknown'; + const toolCallId = typeof call.id === 'string' ? call.id : null; + const prepared = prepareArguments(call.arguments, redact); + const record = push({ + kind: 'toolCall', + title: name, + summary: toolCallSummary(name, prepared.summaryArgs), + toolName: name, + toolCallId, + arguments: prepared.value, + argumentsOmitted: prepared.omitted, + tokens: kind === usageOwner ? carrierUsage() : undefined, + }); + if (toolCallId) toolCallsById.set(toolCallId, { record, toolCallId }); + } + } + if (turn.records.length === 0) { + push({ kind: 'assistant', title: 'assistant', summary: '', text: null, tokens: carrierUsage() }); + } + continue; + } + + if (role === 'toolResult') { + stats.toolResults += 1; + const resultField = clipField(contentToPlainText(content) === null ? null : redact(String(contentToPlainText(content))), MAX_FIELD_CHARS); + const toolCallId = typeof source.toolCallId === 'string' ? source.toolCallId : null; + const toolName = typeof source.toolName === 'string' ? source.toolName : null; + const record = push({ + kind: 'toolResult', + title: toolName ?? 'toolResult', + summary: singleLine(resultField.text ?? '', MAX_SUMMARY_CHARS), + result: resultField.text, + textTruncated: resultField.truncated, + toolName, + toolCallId, + error: source.isError === true && resultField.text ? resultField.text : null, + }); + const pending = toolCallId ? toolCallsById.get(toolCallId) : null; + if (pending && !pending.result) pending.result = record; + continue; + } + + if (role === 'user') { + const plain = contentToPlainText(content) ?? (typeof source.text === 'string' ? source.text : null); + const split = splitUserMessage(source, plain); + + if (split.injected !== null) { + stats.injectedBlocks += 1; + const injectedField = clipField(redact(split.injected), MAX_FIELD_CHARS); + push({ + kind: 'system', + title: 'injected', + summary: singleLine(injectedField.text ?? '', MAX_SUMMARY_CHARS), + text: injectedField.text, + textTruncated: injectedField.truncated, + }); + } + + if (split.prompt !== null) { + stats.userMessages += 1; + if (label === null) label = singleLine(redact(split.prompt), MAX_LABEL_CHARS); + const promptField = clipField(redact(split.prompt), MAX_FIELD_CHARS); + push({ + kind: 'user', + title: 'user', + summary: singleLine(promptField.text ?? '', MAX_SUMMARY_CHARS), + text: promptField.text, + textTruncated: promptField.truncated, + }); + } + + if (split.prompt === null && split.injected === null) { + push({ kind: 'system', title: 'user', summary: '', text: null }); + } + continue; + } + + // A compaction is a context checkpoint written by the runtime, not something the user + // or the model said. It lands in the ledger as its own labelled record because on a long + // session "where did the earlier context go" is the question it answers, and it records + // how large the context had grown before the checkpoint replaced it. + if (role === 'compactionSummary') { + stats.compactions += 1; + const checkpoint = typeof source.summary === 'string' + ? source.summary + : contentToPlainText(content); + const checkpointField = clipField(checkpoint === null ? null : redact(checkpoint), MAX_FIELD_CHARS); + // `history_artifact` is the only thing on this row that says *what happened*: which + // generation it opened, who produced it, and which earlier revision it replaced. + const artifact = row.history_artifact && typeof row.history_artifact === 'object' && !Array.isArray(row.history_artifact) + ? /** @type {Record} */ (row.history_artifact) + : null; + const parent = artifact && artifact.parentSnapshot && typeof artifact.parentSnapshot === 'object' + ? /** @type {Record} */ (artifact.parentSnapshot) + : null; + push({ + kind: 'system', + title: 'compaction', + summary: singleLine(checkpointField.text ?? '', MAX_SUMMARY_CHARS), + text: checkpointField.text, + textTruncated: checkpointField.truncated, + tokensBefore: finiteOrNull(source.tokensBefore), + producedBy: artifact && typeof artifact.producedBy === 'string' ? artifact.producedBy : null, + generation: artifact && Number.isInteger(Number(artifact.generation)) ? Number(artifact.generation) : null, + parentGeneration: parent && Number.isInteger(Number(parent.generation)) ? Number(parent.generation) : null, + parentCompactionId: parent && typeof parent.compactionId === 'string' ? parent.compactionId : null, + parentRevision: parent && typeof parent.revision === 'string' && SAFE_REVISION.test(parent.revision) + ? parent.revision + : null, + }); + continue; + } + + const systemText = contentToPlainText(content) + ?? (typeof source.summary === 'string' ? source.summary : null) + ?? (typeof source.text === 'string' ? source.text : null); + const systemField = clipField(systemText === null ? null : redact(systemText), MAX_FIELD_CHARS); + push({ + kind: 'system', + title: 'system', + summary: singleLine(systemField.text ?? '', MAX_SUMMARY_CHARS), + text: systemField.text, + textTruncated: systemField.truncated, + }); + } + + for (const turn of turns) { + turn.durationMs = turn.startedAt === null || turn.endedAt === null ? null : turn.endedAt - turn.startedAt; + } + + for (const pending of toolCallsById.values()) { + const callRecord = pending.record; + const resultRecord = pending.result; + if (!resultRecord) continue; + const durationMs = callRecord.timestamp === null || resultRecord.timestamp === null + ? null + : resultRecord.timestamp - callRecord.timestamp; + callRecord.durationMs = durationMs; + resultRecord.durationMs = durationMs; + const preview = singleLine(resultRecord.result ?? '', 80); + if (preview) callRecord.summary = singleLine(`${callRecord.summary} → ${preview}`, MAX_SUMMARY_CHARS); + } + + stats.turns = turns.length; + // A raw turn total is the number most likely to be misread. Every turn is exactly one of + // four things, and saying which is the difference between "why is this not what I counted" + // and having to go dig through the file. + // + // 对话 a turn somebody typed into + // 问答 the answering half of a question the previous turn asked — the reply arrives + // through the question itself, so no user message is ever written for it + // 压缩 a turn that opened a context generation; a checkpoint, not conversation + // 其他 a turn with no prompt and nothing pointing at what caused it + // + // The 问答 case is a structural inference, not a guess at the content: walking forward from + // the start of the ledger, a turn that calls a tool which waits on the reader arms the flag, + // and the first prompt-less turn after that is the answer. Any turn carrying a real prompt + // disarms it again, so an ordinary typed reply is never mistaken for one. + let turnsWithPrompt = 0; + let qaTurns = 0; + let compactionTurns = 0; + let awaitingReply = false; + for (const turn of turns) { + let hasPrompt = false; + let isCompaction = false; + let askedReader = false; + for (const record of turn.records) { + // Injected blocks were already demoted to system records, so a user record here is a + // prompt somebody actually typed. + if (record.kind === 'user') hasPrompt = true; + if (record.title === 'compaction') isCompaction = true; + if (record.kind === 'toolCall' && typeof record.toolName === 'string' + && USER_INPUT_TOOLS.has(record.toolName)) askedReader = true; + } + if (isCompaction) compactionTurns += 1; + else if (hasPrompt) { turnsWithPrompt += 1; awaitingReply = false; } + else if (awaitingReply) { qaTurns += 1; awaitingReply = false; } + else stats.turnsWithoutPrompt += 1; + if (askedReader) awaitingReply = true; + } + stats.turnsWithPrompt = turnsWithPrompt; + stats.qaTurns = qaTurns; + stats.compactionTurns = compactionTurns; + if (startedAt !== null && lastTimestamp !== null) stats.durationMs = lastTimestamp - startedAt; + const resolvedLabel = label ?? `会话 ${String(session.id).slice(4, 12)}`; + + const generations = Array.isArray(input.generations) ? input.generations : []; + // `messages` counts what is on screen. When the whole lineage is stitched that already + // spans every generation; when the reader filtered to one, `messagesAll` keeps the + // session-wide number from silently shrinking to a single slice. + const messagesAll = generations.length > 1 + ? generations.reduce((sum, entry) => sum + (Number.isInteger(entry.messageCount) ? entry.messageCount : 0), 0) + : rows.length; + + return { + session: { + id: session.id, + label: resolvedLabel, + messageCount: rows.length, + turnCount: turns.length, + startedAt, + lastActiveAt: session.createdAtMs ?? startedAt, + sizeBytes, + truncated, + generation: input.generation ?? null, + generations, + orphans: Array.isArray(input.orphans) ? input.orphans : [], + }, + stats: { ...stats, messagesAll }, + turns: turns.map((turn) => ({ + id: turn.id, + index: turn.index, + startedAt: turn.startedAt, + endedAt: turn.endedAt, + durationMs: turn.durationMs, + toolCalls: turn.toolCalls, + errors: turn.errors, + tokens: turn.tokens, + records: turn.records, + })), + }; +} + +/** + * Redacts decoded string values in place of the serialized form: rewriting serialized JSON would + * corrupt nested escaped strings (a manifest embedded in `details` re-parses as broken JSON). + * @param {any} value + * @param {(value: string) => string} redact + * @param {number} [depth] + */ +function redactStructure(value, redact, depth = 0) { + if (depth > 24) return null; + if (typeof value === 'string') return redact(value); + if (Array.isArray(value)) return value.map((entry) => redactStructure(entry, redact, depth + 1)); + if (value && typeof value === 'object') { + /** @type {Record} */ + const out = {}; + for (const [key, entry] of Object.entries(value)) { + out[key] = redactStructure(entry, redact, depth + 1); + } + return out; + } + return value; +} + +/** + * @param {Record} message + * @param {(value: string) => string} redact + */ +function buildRawPayload(message, redact) { + if (!message || typeof message !== 'object') return { value: null, omitted: true }; + let serialized = ''; + try { + serialized = JSON.stringify(message); + } catch { + return { value: null, omitted: true }; + } + if (typeof serialized !== 'string') return { value: null, omitted: true }; + if (Buffer.byteLength(serialized, 'utf8') > MAX_RAW_BYTES) return { value: null, omitted: true }; + return { value: serialized.includes('.minimax') ? redactStructure(message, redact) : message, omitted: false }; +} + +/** + * @param {unknown} args + * @param {(value: string) => string} redact + */ +function prepareArguments(args, redact) { + if (args === undefined || args === null) return { value: null, omitted: false, summaryArgs: null }; + if (typeof args !== 'object') return { value: String(args), omitted: false, summaryArgs: String(args) }; + let serialized = ''; + try { + serialized = JSON.stringify(args); + } catch { + return { value: null, omitted: true, summaryArgs: null }; + } + // The summary is truncated to one short line, so it can safely use the uncapped redacted copy. + const redacted = serialized.includes('.minimax') ? redactStructure(args, redact) : args; + if (Buffer.byteLength(serialized, 'utf8') > MAX_ARGUMENT_BYTES) { + return { value: null, omitted: true, summaryArgs: redacted }; + } + return { value: redacted, omitted: false, summaryArgs: redacted }; +} + +/** + * @param {any} response + * @param {number} status + * @param {unknown} payload + */ +function sendJson(response, status, payload) { + if (response.writableEnded || response.destroyed) return; + let body; + try { + body = JSON.stringify(payload); + } catch { + return; + } + try { + response.writeHead(status, { ...JSON_HEADERS, 'content-length': Buffer.byteLength(body, 'utf8') }); + response.end(body); + } catch { + // The client polls aggressively and aborts requests; a closed socket is not an error here. + } +} + +/** + * A weak validator derived only from cheap-to-read facts, so it can be computed *before* + * the expensive file read and still change whenever the payload would change. + * @param {string} material + * @returns {string} + */ +export function weakETag(material) { + return 'W/"' + createHash('sha1').update(material).digest('hex').slice(0, 20) + '"'; +} + +/** + * Matches an `If-None-Match` header against the current tag, per RFC 9110: `*` matches + * anything, and a list matches if any member matches (weak comparison ignores the `W/` prefix). + * @param {string | undefined | null} header + * @param {string} etag + * @returns {boolean} + */ +export function etagMatches(header, etag) { + if (typeof header !== 'string' || header === '') return false; + const target = etag.replace(/^W\//, ''); + return header.split(',').some((candidate) => { + const value = candidate.trim(); + if (value === '*') return true; + return value.replace(/^W\//, '') === target; + }); +} + +/** + * The cache key for one rendered trajectory. Every component comes from a `stat`, a small + * manifest/catalog file, or the already-resolved session binding — never from reading + * `messages.jsonl`, which is the whole point: a matching key proves the file did not grow. + * @param {{ dir: string, sizeBytes: number, lastActiveAt: number }} entry + * @param {{ catalogBytes?: number | null }} identity + * @param {any} binding + * @returns {string} + */ +export function trajectoryCacheKey(entry, identity, binding, filter) { + const bind = binding + ? [ + binding.sessionId ?? '', + binding.title ?? '', + binding.ambiguous ? '1' : '0', + binding.leased ? '1' : '0', + (binding.candidates ?? []).map((c) => `${c.sessionId}:${c.title}`).join(','), + ].join('|') + : 'file-mtime'; + // The stitched answer depends on every generation in the chain, not just the file that is + // being appended to, so the catalog's own numbers have to be part of the key. Without them a + // compaction that only rotated snapshots could serve a stale pre-rotation body. + const lineage = (identity.generations ?? []) + .map((g) => `${g.generation}:${g.fileName}:${g.byteLength ?? ''}`) + .join(','); + // The requested slice is part of the answer too: the same session stitched and unfiltered is + // two different bodies and must never share an entry. + const gen = filter === null || filter === undefined ? 'all' : String(filter); + return `${entry.dir}|gen=${gen}|${lineage}|${entry.sizeBytes}|${entry.lastActiveAt}|${identity.catalogBytes ?? ''}|${bind}`; +} + +/** + * Sends an already-serialized JSON body. Unlike {@link sendJson} it supports revalidation: + * `no-store` would suppress the 304 path, so revalidated responses advertise `no-cache` + * (storable, but must be revalidated) and carry the tag the page echoes back. + * @param {any} response + * @param {number} status + * @param {string} body + * @param {{ etag?: string, revalidate?: boolean }} [options] + */ +export function sendJsonBody(response, status, body, options) { + if (response.writableEnded || response.destroyed) return; + const headers = { ...JSON_HEADERS }; + if (options && options.revalidate) headers['cache-control'] = 'no-cache'; + if (options && options.etag) headers.etag = options.etag; + if (status === 304) { + // A 304 carries no body and must not advertise one. + try { + response.writeHead(status, headers); + response.end(); + } catch { + // Aborted by the client; see sendJson. + } + return; + } + headers['content-length'] = Buffer.byteLength(body, 'utf8'); + try { + response.writeHead(status, headers); + response.end(body); + } catch { + // The client polls aggressively and aborts requests; a closed socket is not an error here. + } +} + +/** @param {any} response */ +function sendNotFound(response) { + sendJson(response, 404, { error: 'not_found' }); +} + +/** @param {any} response @param {string} code @param {string} message */ +function sendError(response, status, code, message) { + sendJson(response, status, { error: code, message }); +} + +/** + * @param {MiniAppContext} context + */ +export async function start(context) { + const location = resolveSessionsRoot(); + const secrets = [location.sessionsRoot, location.root, join(homedir(), '.minimax')]; + const redact = createRedactor(secrets); + /** @type {Buffer | null} */ + let clientEntry = null; + let listCache = { at: 0, entries: null }; + /** @type {Map} */ + const descriptorCache = new Map(); + /** + * Serialized `/api/trajectory` payloads keyed by facts that are cheap to read, so an + * unchanged session is neither re-read from disk nor re-transferred on the next poll. + * @type {Map} + */ + const trajectoryCache = new Map(); + + const loadClientEntry = async () => { + if (clientEntry) return clientEntry; + const buffer = await readFile(join(context.pluginRoot, 'miniapp', 'client', 'index.html')); + clientEntry = buffer; + return buffer; + }; + + const listEntries = async () => { + const now = Date.now(); + if (listCache.entries && now - listCache.at < LIST_CACHE_TTL_MS) return listCache.entries; + const entries = await collectSessionEntries(location.sessionsRoot); + listCache = { at: now, entries }; + return entries; + }; + + const describeSession = async (entry) => { + const cacheKey = `${entry.dir}|${entry.sizeBytes}|${entry.lastActiveAt}`; + const cached = descriptorCache.get(cacheKey); + if (cached && Date.now() - cached.at < SESSION_CACHE_TTL_MS) return cached.value; + const identity = await readSessionIdentity(entry.dir); + const cacheId = `${entry.dir}|${identity.id}`; + let peek = { messages: 0, userMessages: 0, assistantMessages: 0, toolResults: 0, toolCalls: 0, thinkingBlocks: 0, turns: 0 }; + let label = null; + let startedAt = identity.createdAtMs; + try { + const head = await readHead(join(entry.dir, 'messages.jsonl'), LIST_PEEK_BYTES); + peek = countRolesInPrefix(head.text); + label = findFirstUserLabel(head.text, redact); + const firstTimestamp = firstTimestampIn(head.text); + if (firstTimestamp !== null) startedAt = firstTimestamp; + } catch (error) { + context.logger.warn('miniapp.trajectory.read_error', { reason: 'peek_failed', code: errorCode(error) }); + } + const value = { + id: identity.id, + label: label ?? `会话 ${String(identity.id).slice(4, 12)}`, + messageCount: peek.messages, + startedAt, + lastActiveAt: entry.lastActiveAt, + sizeBytes: entry.sizeBytes, + turnCount: peek.turns, + cacheId, + }; + descriptorCache.set(cacheKey, { at: Date.now(), value }); + if (descriptorCache.size > SESSION_CACHE_ENTRIES) { + const oldest = descriptorCache.keys().next(); + if (!oldest.done) descriptorCache.delete(oldest.value); + } + return value; + }; + + /** + * Diagnostics: report what this Node process can actually observe about its own session + * identity. Names and structure only — no environment *values* are returned unless the + * value is itself a session id, which is the one fact this route exists to settle. + */ + const handleRuntime = async (response) => { + let diagError = null; + try { + const SESSION_ID = /mvs_[0-9a-f]{32}/; + /** @type {Record} */ + const envSessionLike = {}; + const envNames = Object.keys(process.env).sort(); + for (const key of envNames) { + const value = process.env[key]; + if (typeof value !== 'string') continue; + const found = SESSION_ID.exec(value); + if (found) envSessionLike[key] = found[0]; + } + const argv = process.argv.map((entry) => (typeof entry === 'string' ? entry.slice(0, 160) : String(entry))); + let sqlite = { available: false, error: null, dbFile: null, exists: false, opened: false, queryError: null, resolvedSessionId: null, conversationCount: null }; + /** @type {any} */ + let mod = null; + try { + mod = await import('node:sqlite'); + sqlite.available = typeof mod.DatabaseSync === 'function'; + } catch (error) { + sqlite.error = String(/** @type {any} */ (error)?.message ?? error).slice(0, 200); + } + if (sqlite.available) { + // Name only — this route must never hand the browser a filesystem location. + sqlite.dbFile = basename(join(location.root, 'v2', 'sqlite', 'runtime-state.sqlite')); + const sqlitePath = join(location.root, 'v2', 'sqlite', 'runtime-state.sqlite'); + sqlite.exists = existsSync(sqlitePath); + try { + const probe = new mod.DatabaseSync(sqlitePath, { readOnly: true }); + const count = probe.prepare( + "select count(*) as n from local_runtime_sessions where session_kind = 'conversation' and purpose is null", + ).get(); + sqlite.conversationCount = count ? toNumber(count.n) : null; + probe.close(); + } catch (error) { + sqlite.queryError = String(/** @type {any} */ (error)?.message ?? error).slice(0, 240); + } + const current = await resolveCurrentConversation(location.root); + sqlite.opened = Boolean(current); + sqlite.resolvedSessionId = current ? current.sessionId : null; + } + sendJson(response, 200, { + nodeVersion: process.version, + pid: process.pid, + ppid: process.ppid, + execArgv: process.execArgv, + argv, + envNames, + envSessionLike, + argvSessionLike: argv.filter((entry) => SESSION_ID.test(entry)), + cwdBasename: basename(process.cwd()) || null, + dataDirBasename: basename(context.dataDir) || null, + sessionRootExists: existsSync(location.sessionsRoot), + sqlite, + }); + } catch (error) { + diagError = String(/** @type {any} */ (error)?.stack ?? error).slice(0, 600); + sendJson(response, 200, { diagError }); + } + }; + + const handleSessions = async (response, url) => { const limit = normalizeLimit(url.searchParams.get('limit'), 20, 1, 50); + let entries; + try { + entries = await listEntries(); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'root_unreadable', code: errorCode(error) }); + sendError(response, 503, 'trajectory_unavailable', '未找到本地会话数据目录'); + return; + } + if (entries.length === 0) { + sendError(response, 503, 'trajectory_unavailable', '未找到本地会话数据目录'); + return; + } + const described = []; + const visible = entries.slice(0, limit); + // Real titles from the runtime database; sub-agent and cron sessions get a kind badge so + // the picker never presents them as if they were the user's conversation. + const titles = await readSessionTitles( + location.root, + visible.map((entry) => decodeSessionIdFromDirName(basename(entry.dir))).filter(Boolean), + ); + for (const entry of visible) { + try { + const descriptor = await describeSession(entry); + const meta = titles.get(decodeSessionIdFromDirName(basename(entry.dir))); + if (meta) { + if (meta.title) descriptor.label = meta.title; + descriptor.sessionKind = meta.kind; + } + described.push(descriptor); + } catch (error) { + context.logger.warn('miniapp.trajectory.read_error', { reason: 'describe_failed', code: errorCode(error) }); + } + } + sendJson(response, 200, { sessions: described.map(stripInternalFields) }); + }; + + const handleTrajectory = async (response, url) => { + let entries; + try { + entries = await listEntries(); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'root_unreadable', code: errorCode(error) }); + sendError(response, 503, 'trajectory_unavailable', '未找到本地会话数据目录'); + return; + } + if (entries.length === 0) { + sendError(response, 503, 'trajectory_unavailable', '未找到本地会话数据目录'); + return; + } + const requested = url.searchParams.get('session'); + let entry = null; + let binding = null; + if (!requested || requested === 'latest') { + // Ask the runtime database which conversation is live before falling back to mtime: + // a worker or cron session often has the newest file even while the user reads another one. + const current = await resolveCurrentConversation(location.root); + if (current) { + entry = entries.find((candidate) => basename(candidate.dir).includes('session_') + && decodeSessionIdFromDirName(basename(candidate.dir)) === current.sessionId) ?? null; + if (!entry && current.relativeDir) { + // Not in the recent window — build the entry straight from the recorded path. + const dir = join(location.sessionsRoot, current.relativeDir.replace(/\\/g, '/')); + try { + const fileStat = await stat(join(dir, 'messages.jsonl')); + entry = { dir, sizeBytes: fileStat.size, lastActiveAt: Math.floor(fileStat.mtimeMs) }; + } catch { + entry = null; + } + } + if (entry) binding = current; + } + if (!entry) entry = entries[0]; + } else { + const matched = []; + for (const candidate of entries) { + const identity = await readSessionIdentity(candidate.dir); + if (identity.id === requested || basename(candidate.dir) === requested) { + matched.push({ entry: candidate, identity }); + break; + } + } + if (matched.length === 0) { + sendError(response, 404, 'session_not_found', '找不到该会话'); + return; + } + entry = matched[0].entry; + } + + const identity = await readSessionIdentity(entry.dir); + + // Which slice of history to show. The default is the whole lineage, because that is what + // the session actually contains: a compaction rotates the old messages into a snapshot + // rather than deleting them, so reading only the active file would show a conversation + // that stops existing at its first checkpoint. `?generation=N` narrows to one slice. + const generations = identity.generations; + const requestedGeneration = url.searchParams.get('generation'); + /** @type {number | null} */ + let filter = null; + if (requestedGeneration !== null && requestedGeneration !== '') { + const wanted = Number(requestedGeneration); + filter = Number.isInteger(wanted) && wanted >= 0 ? wanted : NaN; + if (!generations.some((candidate) => candidate.generation === filter)) { + sendError(response, 404, 'generation_not_found', '找不到该上下文代'); + return; + } + } + + // Everything needed to decide "did this change?" is already in hand. Building the key + // before the read is what lets an unchanged session skip the read entirely. + const cacheKey = trajectoryCacheKey(entry, identity, binding, filter); + const etag = weakETag(cacheKey); + const requestEtag = response.req && response.req.headers ? response.req.headers['if-none-match'] : null; + const cached = trajectoryCache.get(cacheKey); + if (cached) { + if (etagMatches(requestEtag, etag)) { + sendJsonBody(response, 304, '', { etag, revalidate: true }); + return; + } + // No validator from the page (first load, or a fresh tab): reuse the body we already + // built instead of re-reading and re-parsing the files to rebuild the same bytes. + sendJsonBody(response, 200, cached.body, { etag, revalidate: true }); + return; + } + + // Walk the lineage from the active file outward. A small head of each candidate is enough + // to learn which generation it claims to be, so only the files that actually belong to the + // chain get parsed in full. + const artifactByFile = new Map(generations.map((candidate) => [candidate.fileName, candidate])); + /** @type {Map} */ + const probed = new Map(); + const probe = async (generation) => { + const artifact = generation === null + ? { fileName: 'messages.jsonl', active: true, byteLength: identity.catalogBytes } + : generations.find((candidate) => candidate.generation === generation) ?? null; + if (artifact === null) return null; + if (generation !== null && artifact.active) return null; + const filePath = resolveArtifactPath(entry.dir, artifact); + if (filePath === null) return null; + if (probed.has(artifact.fileName)) return probed.get(artifact.fileName); + const limit = artifact.active + ? Math.min(entry.sizeBytes, identity.catalogBytes ?? MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES) + : Math.min(artifact.byteLength ?? MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES); + const head = await readHead(filePath, Math.min(limit, GENERATION_PROBE_BYTES)); + const result = { fileName: artifact.fileName, marker: readGenerationMarker(head.text) }; + probed.set(artifact.fileName, result); + return result; + }; + + /** @type {{ chain: Array<{ generation: number, fileName: string }>, orphans: Array<{ generation: number, fileName: string }> }} */ + let lineage; + try { + lineage = await walkLineage({ generations }, probe); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'lineage_unreadable', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + return; + } + if (lineage.chain.length === 0) { + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + return; + } + const activeFileName = lineage.chain[lineage.chain.length - 1].fileName; + const activeArtifact = artifactByFile.get(activeFileName) ?? null; + + // Concatenate oldest → newest, which is the order the conversation actually happened in. + /** @type {Array>} */ + const rows = []; + let skippedTotal = 0; + let truncated = false; + let readBytes = 0; + for (const link of lineage.chain) { + const artifact = artifactByFile.get(link.fileName) ?? null; + const filePath = artifact === null ? null : resolveArtifactPath(entry.dir, artifact); + if (filePath === null) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'artifact_path_rejected', code: errorCode(null) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + return; + } + const isActive = link.fileName === activeFileName; + const limit = isActive + ? Math.min(entry.sizeBytes, identity.catalogBytes ?? MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES) + : Math.min(artifact === null ? MAX_MESSAGE_BYTES : (artifact.byteLength ?? MAX_MESSAGE_BYTES), MAX_MESSAGE_BYTES); + let head; + try { + head = await readHead(filePath, limit); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'messages_unreadable', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + return; + } + if (head.text === '') { + context.logger.warn('miniapp.trajectory.read_error', { reason: 'empty_messages' }); + } + if (head.read < head.size) truncated = true; + readBytes += head.read; + const parsed = parseMessagesJsonl(head.text); + skippedTotal += parsed.skipped; + const keep = filter === null || filter === link.generation; + for (const row of parsed.rows) { + if (keep) { + row[ROW_GENERATION] = link.generation; + rows.push(row); + } + } + } + if (skippedTotal > 0) { + context.logger.warn('miniapp.trajectory.parse_warn', { skipped: skippedTotal }); + } + + const shown = filter === null ? lineage.chain : lineage.chain.filter((link) => link.generation === filter); + const payload = buildTrajectoryPayload({ + session: { id: identity.id, createdAtMs: entry.lastActiveAt }, + rows, + sizeBytes: readBytes, + truncated, + redact, + generations, + generation: shown.length === 1 ? shown[0].generation : null, + orphans: lineage.orphans, + }); + payload.session.lastActiveAt = entry.lastActiveAt; + payload.session.activeGeneration = activeArtifact === null ? null : activeArtifact.generation; + payload.session.stitched = shown.length > 1; + if (binding) { + // Tell the page how this session was chosen so the footer can be honest about it. + payload.session.binding = binding.ambiguous ? 'ambiguous' : 'runtime-db'; + payload.session.leased = binding.leased; + if (binding.title) payload.session.label = binding.title; + if (binding.ambiguous) { + payload.session.ambiguousCandidates = binding.candidates.map((candidate) => ({ + id: candidate.sessionId, + label: candidate.title, + })); + } + } else { + payload.session.binding = 'file-mtime'; + payload.session.leased = false; + } + + let body; + try { + body = JSON.stringify(payload); + } catch { + sendJson(response, 500, { error: 'trajectory_unserializable', message: '会话数据暂时无法读取' }); + return; + } + trajectoryCache.set(cacheKey, { at: Date.now(), etag, body }); + while (trajectoryCache.size > TRAJECTORY_CACHE_ENTRIES) { + const oldest = trajectoryCache.keys().next(); + if (oldest.done) break; + trajectoryCache.delete(oldest.value); + } + sendJsonBody(response, 200, body, { etag, revalidate: true }); + }; + + const handleDashboard = async (response) => { + try { + const entry = await loadClientEntry(); + if (response.writableEnded || response.destroyed) return; + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'content-length': entry.length, + 'cache-control': 'no-store', + }); + response.end(entry); + } catch (error) { + context.logger.error('miniapp.trajectory.read_error', { reason: 'client_entry_unreadable', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '页面资源暂时无法读取'); + } + }; + + const server = createServer((request, response) => { + const rawUrl = request.url ?? '/'; + response.on('error', () => undefined); + request.on('error', () => undefined); + if (request.method !== 'GET') { + sendNotFound(response); + return; + } + let url; + try { + url = new URL(rawUrl, 'http://miniapp.local'); + } catch { + sendNotFound(response); + return; + } + if (request.destroyed || response.destroyed) return; + if (url.pathname === '/dashboard') { + void handleDashboard(response).catch((error) => { + context.logger.warn('miniapp.request.failed', { route: 'dashboard', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '页面资源暂时无法读取'); + }); + return; + } + if (url.pathname === '/api/runtime') { + void handleRuntime(response).catch((error) => { + context.logger.error('miniapp.trajectory.read_error', { reason: 'runtime_diag_failed', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '运行时诊断暂时不可用'); + }); + return; + } + if (url.pathname === '/api/sessions') { + void handleSessions(response, url).catch((error) => { + context.logger.error('miniapp.trajectory.read_error', { reason: 'sessions_failed', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + }); + return; + } + if (url.pathname === '/api/trajectory') { + void handleTrajectory(response, url).catch((error) => { + context.logger.error('miniapp.trajectory.read_error', { reason: 'trajectory_failed', code: errorCode(error) }); + sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); + }); + return; + } + sendNotFound(response); + }); + + // Keep aborted polls quiet: half-open sockets from frequent client interrupts are normal here. + server.on('clientError', (_error, socket) => { + if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); + else socket.destroy(); + }); + server.on('error', (error) => { + context.logger.error('miniapp.runtime.error', { code: errorCode(error) }); + }); + server.keepAliveTimeout = 5000; + server.headersTimeout = 10000; + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('miniapp.runtime.listening'); + + /** @type {Promise | null} */ + let disposal = null; + /** @type {() => Promise} */ + const dispose = () => { + if (disposal) return disposal; + context.signal.removeEventListener('abort', onAbort); + listCache = { at: 0, entries: null }; + descriptorCache.clear(); + trajectoryCache.clear(); + clientEntry = null; + disposal = close(server).then(() => undefined); + return disposal; + }; + const onAbort = () => { + void dispose().catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +/** @param {any} value */ +function stripInternalFields(value) { + return { + id: value.id, + label: value.label, + messageCount: value.messageCount, + startedAt: value.startedAt, + lastActiveAt: value.lastActiveAt, + sizeBytes: value.sizeBytes, + turnCount: value.turnCount, + sessionKind: value.sessionKind ?? null, + }; +} + +/** + * @param {string} text + * @param {(value: string) => string} redact + */ +function findFirstUserLabel(text, redact) { + for (const line of text.split('\n')) { + if (!line.includes('"role":"user"') && !line.includes('"role": "user"')) continue; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + const message = parsed && typeof parsed === 'object' ? parsed.message : null; + if (!message || message.role !== 'user') continue; + const plain = contentToPlainText(message.content) ?? (typeof message.text === 'string' ? message.text : null); + // Sub-agent sessions have only injected "user" rows; those must not become the label. + const prompt = splitUserMessage(message, plain).prompt; + if (!prompt) continue; + const label = singleLine(redact(prompt), MAX_LABEL_CHARS); + if (label) return label; + } + return null; +} + +/** + * @param {string} text + */ +function firstTimestampIn(text) { + const match = /"timestamp"\s*:\s*(\d{10,16})/.exec(text); + if (!match) return null; + const value = Number.parseInt(match[1], 10); + return Number.isFinite(value) ? value : null; +} + +/** + * @param {unknown} error + */ +function errorCode(error) { + if (error && typeof error === 'object' && typeof (/** @type {any} */ (error).code) === 'string') { + return /** @type {any} */ (error).code; + } + return 'unknown'; +} + +/** + * @param {any} server + * @param {string} host + * @param {number} port + */ +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (/** @type {unknown} */ error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(undefined); + }); + }); +} + +/** + * @param {any} server + */ +function close(server) { + return new Promise((resolve) => { + if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); + server.close(() => resolve(undefined)); + }); +} \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/package.json b/plugins/avatasia/mmc-trajectory/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/avatasia/mmc-trajectory/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} From 715acc750c7f3663acf75c899c5612f5bfb49d12 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sat, 10 Oct 2026 20:36:59 +0800 Subject: [PATCH 02/19] Rebuild trajectory ledger filters as layer switches, add window controls The turn and call toolbar buttons were fold controls: they replaced rows with a collapsed placeholder carrying a one-line summary. They are now layer switches that remove rows from the render outright, leaving no placeholder behind and making the button the only way to bring them back. - turn governs the conversation layer (user, assistant) - call governs the tool layer (toolCall, toolResult) - the two layers do not overlap, so switching off one never takes part of the other with it - turn headers recompute from the rows that survive, so every count under a filter is a real post-filter count This drops the whole fold apparatus: collapsed/collapsedCalls state sets, reapplyFoldIntent, isCollapsed, isTurnFoldable, contentEntries, summarizeTurn, summarizeCalls, callBlocks, buildTurnPlan and the fold-summary styles. Button pressed state is now the visibility itself, so there is no derived state left to drift. Also in this change: - window controls: a sticky window bar offers load 200 earlier, jump back to the latest 200, and show everything. Show-all is a mode, not a big number, so incoming records do not knock it back down to 200 on the next poll. - scroll stability: the ledger restores a row anchor rather than an absolute pixel offset. The window shifts forward as records arrive, so a fixed pixel position drifts even though the scrollbar never moves. - sticky fix: .panel used overflow: hidden, which made it a scroll container and silently killed every position: sticky inside it. Switched to overflow: clip, which also revives the previously dead .turn-head sticky. - filtering: isFiltering and spanPassesFilter now agree entry for entry with visibleEntries. When they drifted, the overview bar drew spans for rows the list refused to render, which is what made filtering look incomplete. READMEs document the layer semantics and the new window controls. --- plugins/avatasia/mmc-trajectory/README.md | 4 +- .../avatasia/mmc-trajectory/README.zh-CN.md | 4 +- .../mmc-trajectory/miniapp/client/index.html | 1644 ++++++++++++++++- 3 files changed, 1558 insertions(+), 94 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/README.md b/plugins/avatasia/mmc-trajectory/README.md index 48da75f..5a7513d 100644 --- a/plugins/avatasia/mmc-trajectory/README.md +++ b/plugins/avatasia/mmc-trajectory/README.md @@ -22,6 +22,8 @@ The page follows the most recent conversation by default and re-reads it every 2 The ledger groups records by turn, following the `turn_id` recorded in the session file. Each record is classified as user, assistant, thinking, tool call, tool result, or system, and each turn header carries that turn's own Token counts and elapsed time. Selecting a record opens a detail panel with summary, raw JSON, tool arguments, tool result, and reasoning, plus a button that hands the record to the Agent's chat input. +The toolbar's turn and call toggles are layer switches rather than collapse controls: turn governs the conversation layer (user and assistant), call governs the tool layer (tool calls and tool results). Turning one off drops those rows from the render entirely — no placeholder summary row is left behind, and the button is the only way to bring them back. The two layers do not overlap, so switching off one never takes part of the other with it, and each turn header recomputes from the rows that survive, which makes every count you see under a filter a real post-filter count. + Values that the session file does not record are rendered as `—`. Nothing is inferred or filled in: a tool call with no matching result has no duration, an assistant message carries no model attribution if the file omitted it, and a message with no Token usage shows no Token usage. An assistant message is always displayed in one fixed order — **reasoning, then the answer, then the tool calls it asked for** — regardless of the order the blocks appear in the session file. Token usage stays with the answer; a message that produced reasoning but no answer shows its usage on the reasoning block, because that is all the message contains. @@ -77,7 +79,7 @@ The runtime database is opened in read-only mode. If it is missing, unreadable, - The app depends on undocumented internal formats: the `v2/sessions` directory layout and the runtime database schema. A client update can change either, which may break session identification or parsing. - Session identification is an inference, not a binding. With one conversation active it is reliable; with none running it degrades to most-recently-written. - A single `messages.jsonl` is read up to 64 MB. Larger sessions are truncated and the page says so. -- The ledger renders 200 records at a time with an explicit "load earlier" control rather than true virtual scrolling. +- The ledger renders only the newest 200 records by default rather than using true virtual scrolling. A sticky window bar offers three explicit controls: load 200 earlier records, jump back to the latest 200, and show everything. "Show everything" builds every record in the session into the DOM at once and gets noticeably slower on large sessions; incoming records do not knock it back down to 200. - `messageCount` and `turnCount` in the session picker are estimates derived from a bounded prefix of the file; exact values come from the selected session. - Light theme token coverage is verified — every `--mcode-*` token the page consumes is defined for both themes — but its rendered appearance was not visually checked; the page was exercised under a dark system preference. diff --git a/plugins/avatasia/mmc-trajectory/README.zh-CN.md b/plugins/avatasia/mmc-trajectory/README.zh-CN.md index b159050..372fb08 100644 --- a/plugins/avatasia/mmc-trajectory/README.zh-CN.md +++ b/plugins/avatasia/mmc-trajectory/README.zh-CN.md @@ -22,6 +22,8 @@ 台账按 `turn_id` 把记录分组,轮次划分完全依据会话文件中记录的 `turn_id`。每条记录被归类为用户、助手、思考、工具调用、工具结果或系统;每个轮次表头带该轮自己的 token 计数与耗时。选中任意记录会打开详情面板,含摘要、原始 JSON、工具入参、工具结果与思考内容,并提供一个把该记录内容送回 Agent 对话输入框的按钮。 +工具栏的「轮次」与「调用」是图层开关,不是折叠:轮次管对话层(用户与助手),调用管工具层(工具调用与工具结果)。取消即不渲染,不留占位摘要行,按钮是唯一的恢复途径。两层互不相交,关掉一层不会顺带走另一层的部分内容;轮次表头按幸存下来的行重算,所以筛选后看到的计数就是筛选后的真实计数。 + 会话文件没有记录的值一律渲染为 `—`,不做任何推断或补全:没有匹配结果的工具调用就没有耗时,会话文件没写模型信息的消息就不带模型归属,没有 token 统计的消息就不显示 token。 一条 assistant 消息始终按固定顺序展示 —— **思考、然后回答、然后它发起的工具调用** —— 与这些块在会话文件里的排列顺序无关。token 用量跟着回答走;只产生思考、没有回答的消息,用量显示在思考记录上,因为那就是该消息的全部内容。 @@ -77,7 +79,7 @@ MiniMax Code 不会告诉 Mini App 是哪段对话打开了它的页面:传给 - 本应用依赖未公开的内部格式:`v2/sessions` 目录结构与运行时数据库表结构。客户端一次更新就可能改动其中之一,导致会话识别或解析失效。 - 会话识别是推断,不是绑定。只有一段对话在跑时它很可靠;没有对话在跑时会降级为「最近写入」。 - 单个 `messages.jsonl` 最多读取 64 MB。更大的会话会被截断,页面会明确说明。 -- 台账每次渲染 200 条记录,配一个显式的「加载更早」控件,不是真正的虚拟滚动。 +- 台账默认只渲染最新 200 条记录,不是真正的虚拟滚动。吸顶的窗口栏提供三个显式控件:「向上加载更早的 200 条」「只看最新」与「全部显示」。「全部显示」会把当前会话的全部记录一次性建进 DOM,超大会话会明显变慢;新记录到达时不会把「全部」弹回 200 条。 - 会话选择器里的 `messageCount` / `turnCount` 是从文件的有界前缀估算的;精确值以选中会话后展示的为准。 - 浅色主题的 token 覆盖已验证(页面用到的每个 `--mcode-*` 变量在两套主题下都有定义),但渲染外观未做视觉检查;功能验收是在深色系统偏好下进行的。 diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 8da2b48..63a2d7e 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -21,6 +21,7 @@ --mcode-border: #0a0a0a14; --mcode-border-strong: #0a0a0af2; --mcode-accent: #0094fc; + --mcode-selection: rgba(0, 148, 252, 0.16); --mcode-success: #04b54b; --mcode-warning: #f56811; --mcode-danger: #f73646; @@ -39,6 +40,7 @@ --mcode-border: #ffffff14; --mcode-border-strong: #fffffff2; --mcode-accent: #0077d9; + --mcode-selection: rgba(0, 119, 217, 0.28); --mcode-success: #009c3d; --mcode-warning: #e25507; --mcode-danger: #e31937; @@ -59,6 +61,7 @@ --mcode-border: #ffffff14; --mcode-border-strong: #fffffff2; --mcode-accent: #0077d9; + --mcode-selection: rgba(0, 119, 217, 0.28); --mcode-success: #009c3d; --mcode-warning: #e25507; --mcode-danger: #e31937; @@ -139,6 +142,51 @@ font-weight: 600; } + /* A title row plus a disclosure toggle, so the reader can hand the vertical + space back to the ledger once they know what this page is. */ + .title-row { + display: flex; + align-items: center; + gap: 4px; + } + + .section-toggle { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + color: var(--mcode-text-muted); + background: transparent; + cursor: pointer; + } + + .section-toggle:hover { + color: var(--mcode-text); + background: var(--mcode-surface-muted); + } + + .section-toggle:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; + } + + .section-toggle .icon { + transition: transform 150ms ease-out; + } + + .section-toggle[aria-expanded='false'] .icon { + transform: rotate(-90deg); + } + + .section-toggle[hidden] { + display: none; + } + .page-subtitle { margin: 4px 0 0; font-size: 14px; @@ -146,6 +194,10 @@ color: var(--mcode-text-muted); } + .page-subtitle[hidden] { + display: none; + } + .header-actions { display: flex; flex-wrap: wrap; @@ -309,6 +361,12 @@ background: var(--mcode-surface); } + /* display:flex here would outrank the user-agent [hidden] rule, leaving the + query conditions on screen after the reader folded them away. */ + .toolbar[hidden] { + display: none; + } + .toolbar-row { display: flex; flex-wrap: wrap; @@ -323,6 +381,12 @@ min-width: 0; } + /* Same reason as .bar[hidden] below: an author `display` outranks the user-agent + rule, so a field toggled off with `hidden` would stay on screen. */ + .field[hidden] { + display: none; + } + .field-label { font-size: 12px; color: var(--mcode-text-muted); @@ -435,12 +499,34 @@ font-weight: 500; } + /* Sits beside the section title to say which number the panel is showing. */ + .section-scope { + font-size: 11px; + line-height: 16px; + color: var(--mcode-text-muted); + padding: 1px 6px; + border: 1px solid var(--mcode-border); + border-radius: 4px; + white-space: nowrap; + } + + /* `inline-flex` here would otherwise outrank the user-agent rule for [hidden]. */ + .section-scope[hidden] { + display: none; + } + .kpi-grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.1fr 1.1fr; gap: 12px; } + /* The grid is display:grid, which would outrank the user-agent rule for + [hidden] and leave the cards on screen after folding them away. */ + .kpi-grid[hidden] { + display: none; + } + .kpi-card { display: flex; flex-direction: column; @@ -479,6 +565,303 @@ color: var(--mcode-text-subtle); } + /* ---------------- view controls (dsh TrajectoryToolbar) ---------------- + Dimensions and states copied from the reference toolbar: a 20px control, + 12px label, no fill until hovered or pressed, and a code-font ⊟/⊞ glyph + that says at a glance whether the fold is on. */ + + .traj-controls { + position: sticky; + top: 0; + z-index: 5; + display: flex; + flex: none; + align-items: center; + gap: 2px; + margin: 0; + padding: 6px 16px; + background: var(--mcode-bg); + } + + .tbtn { + display: inline-flex; + flex: none; + align-items: center; + height: 20px; + padding: 0 5px; + gap: 4px; + border: 0; + border-radius: 4px; + color: var(--mcode-text-muted); + background: transparent; + cursor: pointer; + font-size: 12px; + line-height: 20px; + } + + .tbtn-toggle { + padding: 0 7px; + } + + .tbtn:hover { + color: var(--mcode-text); + background: var(--mcode-surface-muted); + } + + .tbtn:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; + } + + .tbtn[aria-pressed='true'] { + color: var(--mcode-accent); + background: var(--mcode-selection); + box-shadow: inset 0 0 0 1px var(--mcode-accent); + font-weight: 600; + } + + .tbtn-icon { + flex: none; + width: 12px; + height: 12px; + } + + .tbtn-glyph { + flex: none; + color: var(--mcode-text-muted); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 14px; + line-height: 14px; + } + + .tbtn[aria-pressed='true'] .tbtn-glyph { + color: var(--mcode-text); + } + + /* ---------------- overview timeline (dsh) ---------------- + A fixed 50px projection of the same records the ledger lists, on three + lanes. Spans are absolutely positioned from a computed domain, exactly + like the reference: the strip is a picture of the ledger, never a + second source of numbers. */ + + .overview { + position: sticky; + top: 32px; + z-index: 4; + border-bottom: 1px solid var(--mcode-border); + user-select: none; + } + + .overview-plot { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + height: 50px; + overflow: hidden; + background: var(--mcode-surface-muted); + } + + .overview-labels { + position: relative; + border-right: 1px solid var(--mcode-border); + color: var(--mcode-text-subtle); + font-size: 10px; + line-height: 1; + } + + .overview-labels span { + position: absolute; + right: 4px; + display: flex; + align-items: center; + justify-content: flex-end; + height: 8px; + } + + .overview-labels span:nth-child(1) { top: 7px; } + .overview-labels span:nth-child(2) { top: 21px; } + .overview-labels span:nth-child(3) { top: 35px; } + + .overview-track { + position: relative; + overflow: hidden; + cursor: crosshair; + touch-action: none; + } + + .overview-track[data-panning='true'] { cursor: grabbing; } + + .overview-track:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: -2px; + } + + .overview-turns { + position: absolute; + z-index: 3; + top: 0; + bottom: 0; + left: var(--ov-domain-left, 0%); + width: var(--ov-domain-width, 100%); + pointer-events: none; + } + + .ov-turn { + position: absolute; + top: 0; + bottom: 0; + left: var(--ov-turn-left); + width: 1px; + background: var(--mcode-border); + } + + .overview-lanes { + position: absolute; + z-index: 2; + top: 7px; + bottom: 7px; + left: var(--ov-domain-left, 0%); + width: var(--ov-domain-width, 100%); + } + + .ov-span { + position: absolute; + top: calc(var(--ov-lane) * 14px); + left: calc(var(--ov-left) + var(--ov-gap)); + width: max(2px, calc(var(--ov-width) - var(--ov-gap) - var(--ov-gap))); + height: 8px; + min-width: 2px; + border-radius: 1px; + background: var(--mcode-text-subtle); + opacity: 0.78; + } + + .ov-span[data-kind='user'] { background: var(--mcode-accent); opacity: 1; } + + .ov-span[data-kind='assistant'], + .ov-span[data-kind='thinking'] { + background: var(--mcode-text); + opacity: 1; + } + + .ov-span[data-kind='toolCall'], + .ov-span[data-kind='toolResult'] { + background: var(--mcode-warning); + opacity: 1; + } + + .ov-span[data-compaction='true'] { + background: var(--mcode-success); + opacity: 1; + } + + .ov-span[data-error='true'] { + background: var(--mcode-danger); + opacity: 1; + } + + .ov-span[data-current='true'] { + z-index: 1; + opacity: 1; + box-shadow: 0 0 0 1px var(--mcode-surface-muted), 0 0 0 2px var(--mcode-accent); + } + + .ov-span[data-hovered='true']:not([data-current='true']) { + z-index: 1; + opacity: 1; + box-shadow: 0 0 0 1px var(--mcode-surface-muted), 0 0 0 2px var(--mcode-accent); + } + + .overview-selection { + position: absolute; + z-index: 1; + top: 0; + bottom: 0; + left: var(--ov-sel-left); + width: var(--ov-sel-width); + min-width: 1px; + background: var(--mcode-selection); + pointer-events: none; + } + + .overview-selection::before, + .overview-selection::after { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + background: var(--mcode-accent); + content: ''; + } + + .overview-selection::before { left: 0; } + .overview-selection::after { right: 0; } + + .overview-hover { + position: absolute; + z-index: 4; + top: 0; + bottom: 0; + left: clamp(0px, calc(var(--ov-hover-left) - 1px), calc(100% - 2px)); + width: 2px; + background: var(--mcode-accent); + pointer-events: none; + } + + .overview-empty { + position: absolute; + top: 50%; + left: 50%; + margin: 0; + transform: translate(-50%, -50%); + color: var(--mcode-text-subtle); + font-size: 12px; + } + + .overview-tip { + position: absolute; + z-index: 6; + top: calc(100% + 4px); + left: var(--ov-tip-left, 0%); + max-width: 280px; + padding: 6px 8px; + border: 1px solid var(--mcode-border-strong); + border-radius: 6px; + background: var(--mcode-surface); + color: var(--mcode-text); + font-size: 11px; + line-height: 1.5; + white-space: pre-line; + pointer-events: none; + } + + .overview-clear { + position: absolute; + z-index: 5; + top: 4px; + right: 4px; + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 7px; + border: 1px solid var(--mcode-border-strong); + border-radius: 6px; + background: var(--mcode-surface); + color: var(--mcode-text); + cursor: pointer; + font-size: 11px; + } + + .overview-clear:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; + } + + /* `display` here would otherwise outrank the user-agent rule for [hidden], + leaving the button on screen with nothing to clear. */ + .overview-clear[hidden] { + display: none; + } + /* ---------------- two columns ---------------- */ .columns { @@ -493,7 +876,12 @@ border-radius: 12px; background: var(--mcode-surface); min-width: 0; - overflow: hidden; + /* `clip`, not `hidden`. Both cut content off at the rounded corner, but `hidden` + also makes the element a scroll container — and a sticky descendant of a scroll + container can only stick inside that container. With `hidden` here, the sticky + turn head and the window controls were silently pinned to a box that never + scrolls, so neither ever moved. `clip` clips without creating a scroll container. */ + overflow: clip; } .panel-head { @@ -552,6 +940,15 @@ transform: rotate(-90deg); } + /* A turn with nothing to fold keeps the ruler look but drops the button affordance. */ + .turn-head-static { + cursor: default; + } + + .turn-head-static:hover { + background: var(--mcode-bg-secondary); + } + .turn-title { font-size: 14px; font-weight: 500; @@ -777,8 +1174,42 @@ max-width: 46ch; } - .load-more-wrap { - padding: 8px 16px 0; + .window-bar { + position: sticky; + /* Sticky offsets stack here: the controls own 0–32 and the overview strip owns + 32–83 (32 + the 50px plot + its 1px border), so a bar pinned at 0 would simply + hide behind both of them. z-index 3 keeps it under the overview (4) and over the + turn head (2). */ + top: 83px; + z-index: 3; + padding: 8px 16px; + background: var(--mcode-bg); + border-bottom: 1px solid var(--mcode-border); + } + + .window-bar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .window-bar-count { + font-size: 12px; + color: var(--mcode-text-muted); + margin-left: auto; + } + + /* The state a button reports is already spelled out in its own label, so the quiet + variant only has to stop competing for attention — it must never be the thing that + carries the meaning. */ + .btn-quiet { + color: var(--mcode-text-muted); + } + + .btn:disabled { + opacity: 0.5; + cursor: default; } .ledger-foot { @@ -1083,8 +1514,15 @@
@@ -1644,6 +1659,7 @@

轨迹

+
@@ -1700,10 +1716,14 @@

检视

thinking: '思考', toolCall: '工具调用', toolResult: '工具结果', - system: '系统' + system: '系统', + compaction: '压缩' }; - var KIND_ORDER = ['user', 'assistant', 'thinking', 'toolCall', 'toolResult', 'system']; + // 压缩 is last on purpose. It is written by the runtime like 系统 is, but it is a + // context checkpoint rather than a note about the run, and putting it next to 系统 + // would invite reading the two as one category. + var KIND_ORDER = ['user', 'assistant', 'thinking', 'toolCall', 'toolResult', 'system', 'compaction']; // The type chips are the only filter. Which record kinds are visible is one array — // state.kinds — and every control that changes it writes to that array and nowhere @@ -1717,19 +1737,34 @@

检视

var DASH = '—'; // One label per record, used by every surface that names a kind: the row pill, the - // inspector locator, the inspector detail table and the handoff text. A compaction - // shares kind 'system' with the injected blocks but is its own thing, so all of them - // have to say 压缩 rather than 系统 — hence one function instead of four call sites. + // inspector locator, the inspector detail table and the handoff text. A compaction is + // its own kind, so it needs no special case here — which is the point: a label that + // had to be overridden by a second field is a kind the filter could not see. function kindLabel(record) { - return record && record.title === 'compaction' - ? '压缩' - : (KIND_LABELS[record && record.kind] || (record && record.kind) || DASH); + return (record && KIND_LABELS[record.kind]) || (record && record.kind) || DASH; } function isNil(value) { return value === null || value === undefined || value === ''; } + /** + * What the 总览轴 button says in each mode. Pure, so the wording can be asserted + * without a DOM — and asserted, because a control whose purpose the reader has to + * guess is a defect that no functional test would ever catch. + * + * Both halves have to name two things: what the strip is doing now, and what clicking + * will do instead. 「时长」 named neither and was taken for a duration filter. + * + * @param {boolean} actualDuration + * @returns {{pressed: string, label: string}} + */ + function axisButtonState(actualDuration) { + return actualDuration + ? { pressed: 'true', label: '总览条当前按真实时间轴显示:宽度等于该记录的真实耗时 · 点击改为等宽,每条记录一样宽' } + : { pressed: 'false', label: '总览条当前等宽显示:每条记录一样宽,与耗时无关 · 点击改为真实时间轴' }; + } + function isCount(value) { return typeof value === 'number' && isFinite(value); } @@ -2192,7 +2227,7 @@

检视

dom.generationSelect = document.getElementById('generation-select'); dom.generationHint = document.getElementById('generation-hint'); dom.searchInput = document.getElementById('search-input'); - dom.durationBtn = document.getElementById('duration-btn'); + dom.windowBar = document.getElementById('window-bar'); dom.chipAll = document.getElementById('chip-all'); dom.filterStatus = document.getElementById('filter-status'); dom.headerToggle = document.getElementById('header-toggle'); @@ -2559,6 +2594,8 @@

检视

var OVERVIEW_EDGE_PAN_MAX_PX = 32; var overviewCache = { model: null, view: null, fullDuration: 1, minDuration: 1 }; + // The window bar's nodes, built on first use and then reused. Null until then. + var windowBarParts = null; var overviewDrag = null; var overviewHoverTimer = null; var overviewEdgeTimer = null; @@ -2780,7 +2817,6 @@

检视

var width = (span.end - span.start) / fullDuration; var node = el('span', 'ov-span'); node.setAttribute('data-kind', span.record.kind); - if (span.record.title === 'compaction') node.setAttribute('data-compaction', 'true'); if (span.record.kind === 'toolResult' && span.record.isError === true) node.setAttribute('data-error', 'true'); if (span.record.id === state.selectedId) node.setAttribute('data-current', 'true'); if (state.overviewHoverId === span.id) node.setAttribute('data-hovered', 'true'); @@ -2861,25 +2897,50 @@

检视

state.overviewSelection = null; state.overviewViewport = null; setWindowMode('latest'); - renderViewControls(); renderOverview(); renderLedger(); } /** - * 时长 reads as "what the strip is drawing", not "what was clicked": lit when the - * overview lays records out on the real clock, dark when every record gets an equal - * slice. It is the one view control left, and it changes the projection rather than - * the list — the rows below are the same either way. + * 总览轴 reads as "what the strip is drawing", not "what was clicked": lit when the + * strip lays records out on the real clock, dark when every record gets an equal + * slice. It changes the projection rather than the list — the rows below are the same + * either way, and no count on the page moves. + * + * The button says which axis the strip is on and what the click would switch to. The + * old label was 「时长」, which named neither the strip nor the axis and read as a + * filter on duration — a control whose purpose has to be guessed is one the reader + * cannot use, whatever it does under the hood. */ - function renderViewControls() { - var durationOn = state.actualDuration; - dom.durationBtn.setAttribute('aria-pressed', durationOn ? 'true' : 'false'); - var durationLabel = durationOn - ? '总览当前按真实时长显示 · 点击改为等宽' - : '总览当前等宽显示 · 点击改为真实时长'; - dom.durationBtn.setAttribute('aria-label', durationLabel); - dom.durationBtn.setAttribute('title', durationLabel); + function applyAxisButton(button) { + var view = axisButtonState(state.actualDuration); + button.setAttribute('aria-pressed', view.pressed); + button.setAttribute('aria-label', view.label); + button.setAttribute('title', view.label); + } + + /** A clock, because the axis it switches is a clock rather than a record count. */ + function axisIcon() { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'tbtn-icon'); + svg.setAttribute('width', '12'); + svg.setAttribute('height', '12'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.25'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('aria-hidden', 'true'); + var face = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + face.setAttribute('cx', '8'); + face.setAttribute('cy', '8'); + face.setAttribute('r', '5.25'); + var hands = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + hands.setAttribute('d', 'M8 4.75V8l2.25 1.5'); + svg.appendChild(face); + svg.appendChild(hands); + return svg; } /** @@ -3293,13 +3354,7 @@

检视

row.appendChild(el('span', 'row-index', '#' + (isCount(record.index) ? record.index : DASH))); - // A compaction is a checkpoint, not something anyone said — label it as its own - // thing instead of folding it into 系统, which is also where injected blocks land. - var isCompaction = record.title === 'compaction'; - var label = pill( - kindLabel(record), - isCompaction ? 'compaction' : kindTone(record.kind) - ); + var label = pill(kindLabel(record), kindTone(record.kind)); row.appendChild(label); var summaryText = record.summary || record.text || record.toolName || record.title || ''; @@ -3341,6 +3396,7 @@

检视

if (kind === 'thinking') return 'thinking'; if (kind === 'toolCall') return 'toolcall'; if (kind === 'toolResult') return 'toolresult'; + if (kind === 'compaction') return 'compaction'; return 'system'; } @@ -3565,7 +3621,7 @@

检视

var windowed = matched.length > state.limit ? matched.slice(matched.length - state.limit) : matched; var hiddenAbove = matched.length - windowed.length; - ledger.appendChild(buildWindowBar(windowed, hiddenAbove, total)); + updateWindowBar(windowed, hiddenAbove, total); var groups = visibleTurns(windowed); for (var i = 0; i < groups.length; i += 1) { @@ -3599,52 +3655,71 @@

检视

* * The bar carries the count too, since a count only at the foot of a list is a count * nobody reads until they have already scrolled past everything. + * + * 总览轴 lives here, directly under the strip it changes, rather than with the + * filters at the top of the page: a control that edits something a screen away + * reads as a filter, which is how it ended up labelled 「时长」 and mistaken for one. + * + * Built once into its own container and then only updated. These used to be rebuilt + * on every render, and a render happens every poll — a click that landed between the + * clear and the re-append went to a button that was no longer in the document. */ - function buildWindowBar(shown, hiddenAbove, total) { - var wrap = el('div', 'window-bar'); - var actions = el('div', 'window-bar-actions'); - - if (hiddenAbove > 0) { - var more = el('button', 'btn', '向上加载更早的 ' + formatInt(Math.min(PAGE_STEP, hiddenAbove)) + ' 条'); - more.type = 'button'; - more.addEventListener('click', function () { - // No scroll correction here on purpose: renderLedger anchors on the topmost - // visible row and puts it back, so adding a second adjustment here would - // double-count the shift and leave the page visibly off. - state.limit += PAGE_STEP; - renderLedger(); - }); - actions.appendChild(more); + function updateWindowBar(shown, hiddenAbove, total) { + var wrap = dom.windowBar; + var parts = windowBarParts; + if (!parts) { + parts = windowBarParts = buildWindowBarParts(); + clear(wrap); + wrap.appendChild(parts.actions); } - var count = el('span', 'window-bar-count', + applyAxisButton(parts.axis); + applyWindowMode(parts, hiddenAbove); + parts.count.textContent = '已显示 ' + formatInt(shown.length) + ' / 共 ' + formatInt(total) + ' 条记录' + - (hiddenAbove > 0 ? '(更早的 ' + formatInt(hiddenAbove) + ' 条尚未加载)' : '')); + (hiddenAbove > 0 ? '(更早的 ' + formatInt(hiddenAbove) + ' 条尚未加载)' : ''); + } + + /** One-time construction. Every handler is attached here and never moves again. */ + function buildWindowBarParts() { + var actions = el('div', 'window-bar-actions'); - var latest = el('button', 'btn' + (state.allRecords ? '' : ' btn-quiet'), - state.allRecords ? '已显示全部 · 点此只看最新' : '只看最新 ' + formatInt(PAGE_STEP) + ' 条'); + // First, because it is the only one that changes what the strip above shows rather + // than how many rows are below. + var axis = el('button', 'tbtn tbtn-toggle window-bar-axis'); + axis.type = 'button'; + axis.appendChild(axisIcon()); + axis.appendChild(el('span', null, '总览轴')); + axis.addEventListener('click', function () { + setActualDuration(!state.actualDuration); + }); + actions.appendChild(axis); + + var more = el('button', 'btn'); + more.type = 'button'; + more.addEventListener('click', function () { + // No scroll correction here on purpose: renderLedger anchors on the topmost + // visible row and puts it back, so adding a second adjustment here would + // double-count the shift and leave the page visibly off. + state.limit += PAGE_STEP; + renderLedger(); + }); + actions.appendChild(more); + + var latest = el('button', 'btn btn-quiet'); latest.type = 'button'; - latest.setAttribute('aria-pressed', state.allRecords ? 'false' : 'true'); latest.addEventListener('click', function () { - if (state.allRecords) { - setWindowMode('latest'); - renderLedger(); - // Back to the tail means back to the tail: land on the newest record rather - // than wherever the 4,000-row view happened to leave the reader. - var doc = document.documentElement; - window.scrollTo(0, Math.max(doc.scrollHeight, document.body.scrollHeight)); - } else { - setWindowMode('latest'); - renderLedger(); - } + setWindowMode('latest'); + renderLedger(); + // Back to the tail means back to the tail: land on the newest record rather + // than wherever the 4,000-row view happened to leave the reader. + var doc = document.documentElement; + window.scrollTo(0, Math.max(doc.scrollHeight, document.body.scrollHeight)); }); actions.appendChild(latest); - var all = el('button', 'btn' + (state.allRecords ? ' btn-quiet' : ''), - state.allRecords ? '已显示全部' : '全部显示'); + var all = el('button', 'btn'); all.type = 'button'; - all.setAttribute('aria-pressed', state.allRecords ? 'true' : 'false'); - all.disabled = state.allRecords; all.addEventListener('click', function () { setWindowMode('all'); renderLedger(); @@ -3653,10 +3728,31 @@

检视

// Count last, pushed to the far edge. Sitting it between the buttons made the row // wrap on any session with a five-figure record count. + var count = el('span', 'window-bar-count'); actions.appendChild(count); - wrap.appendChild(actions); - return wrap; + return { actions: actions, axis: axis, more: more, latest: latest, all: all, count: count }; + } + + /** + * 「只看最新」 and 「全部显示」 are each other's inverse, so exactly one is offered at + * any moment, and the other is stated as the way back. Reading the same button as + * both would make its pressed state mean "whichever is currently not true". + */ + function applyWindowMode(parts, hiddenAbove) { + parts.more.hidden = hiddenAbove <= 0; + if (hiddenAbove > 0) { + parts.more.textContent = '向上加载更早的 ' + formatInt(Math.min(PAGE_STEP, hiddenAbove)) + ' 条'; + } + parts.latest.textContent = state.allRecords + ? '已显示全部 · 点此只看最新' + : '只看最新 ' + formatInt(PAGE_STEP) + ' 条'; + parts.latest.setAttribute('aria-pressed', state.allRecords ? 'false' : 'true'); + parts.latest.classList.toggle('btn-quiet', !state.allRecords); + parts.all.textContent = '已显示全部'; + parts.all.setAttribute('aria-pressed', state.allRecords ? 'true' : 'false'); + parts.all.disabled = state.allRecords; + parts.all.classList.toggle('btn-quiet', !state.allRecords); } /** The topmost row that intersects the viewport, as {id, top}, or null. */ @@ -3755,7 +3851,6 @@

检视

setWindowMode('latest'); if (dom.searchInput) dom.searchInput.value = ''; syncChipInputs(); - renderViewControls(); render(); } @@ -3932,7 +4027,7 @@

检视

var table = el('table', 'kv-table'); var caption = el('caption', 'sr-only', '记录元信息'); table.appendChild(caption); - kvRow(table, '类型', record.title === 'compaction' ? '压缩(上下文检查点)' : kindLabel(record)); + kvRow(table, '类型', record.kind === 'compaction' ? '压缩(上下文检查点)' : kindLabel(record)); kvRow(table, '角色', isNil(record.role) ? DASH : String(record.role)); kvRow(table, '轮次', isCount(turn && turn.index) ? '轮次 ' + turn.index : DASH); kvRow(table, '记录序号', isCount(record.index) ? '#' + record.index : DASH); @@ -4248,7 +4343,6 @@

检视

renderKpi(); renderOverview(); renderLedger(); - renderViewControls(); renderInspector(); renderFooter(); updateHandoffButton(); @@ -4388,7 +4482,6 @@

检视

state.kinds = next; setWindowMode('latest'); syncChipInputs(); - renderViewControls(); renderLedger(); updateFilterStatus(visibleEntries().length, state.flat.length); } @@ -4472,7 +4565,6 @@

检视

} setWindowMode('latest'); syncChipInputs(); - renderViewControls(); renderLedger(); updateFilterStatus(visibleEntries().length, state.flat.length); }); @@ -4519,10 +4611,6 @@

检视

event.preventDefault(); }); - dom.durationBtn.addEventListener('click', function () { - setActualDuration(!state.actualDuration); - }); - dom.copyBtn.addEventListener('click', copySelected); dom.handoffBtn.addEventListener('click', handoffSelected); @@ -4550,7 +4638,6 @@

检视

cacheDom(); wire(); syncChipInputs(); - renderViewControls(); renderSectionToggles(); render(); fetchSessions(); @@ -4565,4 +4652,4 @@

检视

} - + \ No newline at end of file diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index 0191269..def6a17 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -1078,9 +1078,10 @@ export function buildTrajectoryPayload(input) { } // A compaction is a context checkpoint written by the runtime, not something the user - // or the model said. It lands in the ledger as its own labelled record because on a long - // session "where did the earlier context go" is the question it answers, and it records - // how large the context had grown before the checkpoint replaced it. + // or the model said. It gets its own kind rather than riding inside `system` because + // the client filters on kind alone: filed under `system`, a checkpoint would be + // impossible to keep or drop on its own, and unticking 系统 would take every context + // checkpoint with it. `title` still says which of the `system` family it is. if (role === 'compactionSummary') { stats.compactions += 1; const checkpoint = typeof source.summary === 'string' @@ -1096,7 +1097,7 @@ export function buildTrajectoryPayload(input) { ? /** @type {Record} */ (artifact.parentSnapshot) : null; push({ - kind: 'system', + kind: 'compaction', title: 'compaction', summary: singleLine(checkpointField.text ?? '', MAX_SUMMARY_CHARS), text: checkpointField.text, @@ -1170,7 +1171,7 @@ export function buildTrajectoryPayload(input) { // Injected blocks were already demoted to system records, so a user record here is a // prompt somebody actually typed. if (record.kind === 'user') hasPrompt = true; - if (record.title === 'compaction') isCompaction = true; + if (record.kind === 'compaction') isCompaction = true; if (record.kind === 'toolCall' && typeof record.toolName === 'string' && USER_INPUT_TOOLS.has(record.toolName)) askedReader = true; } From 10f033f697b0438503b292ba3c832b67863f12e3 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 02:05:48 +0800 Subject: [PATCH 10/19] Open on the newest 200 records, and fix what the review turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ledger builds a real element per row — there is no virtual scrolling — so rendering the whole session at once is not an option to offer by default. On a 9,900-record session that is roughly 57,000 elements rebuilt every 2.5s poll, which was measured to freeze the page. The page now opens on the newest 200 and the window bar switches to everything on request. - The button names the action pressing it performs, not the state it is in: 「显示全部」 at the default, 「显示最新 200 条」 once everything is showing. The count beside it reports what is on screen; the two answer different questions and so cannot contradict each other. `aria-pressed` carries whether the full view is on and `aria-label` restates it as a sentence. - 「向上加载更早的 200 条」 widens the window, and a poll no longer undoes it. `rePinWindow()` used to reset the limit unconditionally, so the button never actually worked — every 2.5s poll took it straight back to 200. It now leaves a widened window alone and only re-pins a window that is exactly the default size, which is what "follow the tail" means. - The button does not scroll the page away from the reader's place. - `.btn[hidden]` is now honoured. `display: inline-flex` was overriding the UA rule, so a hidden button stayed on screen with no label in it. Six sibling selectors already carried this patch; `.btn` was the one that did not. - `DEFAULT_WINDOW_MODE` is a single constant that every reset reads, rather than eight copies of the literal that could disagree with each other. Six review comments: - An empty `records` array was read as "this whole turn is empty"; it is per message, so the test now looks at this message's `segmentOrder`. - `generations` listed the whole catalog, so a generation the lineage walk had rejected — a snapshot left behind by a fork — was counted into the session-wide total and offered in the picker, where selecting it resolved to an empty view. The payload now reports the chain, and still discloses the rejected ones through `orphans`. - The footer claimed a cut at 8 MB while the server reads 64 MB. The cap is sent with the payload and rendered from it, so the two cannot drift again. - The active file's read was bounded by the catalog's recorded size. That size is a snapshot and the file keeps being appended to afterwards, so an actively written session could stop the read short of data that was really there and then report itself as "file too large". The read is bounded by our own cap, which is the only thing that can actually truncate, and `truncatedByCap()` is now the single place that decides it. - `/api/runtime` returned `argv` (whose first element is the node path, and on Windows a username), `execArgv`, `pid` and `ppid`. The page never reads that route; it now reports only the four facts it can state safely. - `resolveCurrentConversation` and `readSessionTitles` opened a SQLite handle per session and never closed it, so a long session picker leaked a handle per entry. Both close in a `finally`, including on the early return. --- plugins/avatasia/mmc-trajectory/README.md | 39 +- .../avatasia/mmc-trajectory/README.zh-CN.md | 25 +- .../mmc-trajectory/miniapp/client/index.html | 808 ++++++++++++++---- .../mmc-trajectory/miniapp/node/server.mjs | 194 ++++- 4 files changed, 833 insertions(+), 233 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/README.md b/plugins/avatasia/mmc-trajectory/README.md index ec6ca4b..ef8403d 100644 --- a/plugins/avatasia/mmc-trajectory/README.md +++ b/plugins/avatasia/mmc-trajectory/README.md @@ -20,23 +20,46 @@ The page follows the most recent conversation by default and re-reads it every 2 ## What it shows -The ledger groups records by turn, following the `turn_id` recorded in the session file. Each record is classified as user, assistant, thinking, tool call, tool result, system, or compaction, and each turn header carries that turn's own Token counts and elapsed time. Selecting a record opens a detail panel with summary, raw JSON, tool arguments, tool result, and reasoning, plus a button that hands the record to the Agent's chat input. +The ledger groups records by turn, following the `turn_id` recorded in the session file. Each record is classified as user, answer, narration, thinking, tool call, tool result, system, or compaction, and each turn header carries that turn's own Token counts and elapsed time. Selecting a record opens a detail panel with summary, raw JSON, tool arguments, tool result, and reasoning, plus a button that hands the record to the Agent's chat input. -**The type chips are the only filter.** One thing is being filtered — which record kinds are visible — and it is one array in the client, written from exactly three controls: the six chips, 全部, and 清除筛选. An earlier version also had a row of layer buttons (turn / thinking / call) sitting above the list as presets over those same chips. They were removed, because any preset a reader outgrows becomes a button that contradicts the chips it was supposed to summarise: untick 助手 while leaving 用户 ticked, and the turn button goes dark and claims the conversation is hidden when in fact it was narrowed. One control that cannot drift is worth more than three convenient ones that can. +**The type chips are the only filter.** One thing is being filtered — which record kinds are visible — and it is one array in the client, written from exactly three controls: the eight kind buttons, 全部, and 清除筛选. An earlier version also had a row of layer buttons (turn / thinking / call) sitting above the list as presets over those same chips. They were removed, because any preset a reader outgrows becomes a button that contradicts the chips it was supposed to summarise: untick 问答 while leaving 用户 ticked, and the turn button goes dark and claims the conversation is hidden when in fact it was narrowed. One control that cannot drift is worth more than three convenient ones that can. -Naming kinds directly costs one extra click and reaches everything a preset did — tick 用户 *and* 助手 for the conversation, untick both for its absence, and any scattered subset in between, such as only the user's questions or only system records. +Naming kinds directly costs one extra click and reaches everything a preset did — tick 用户 *and* 答复 for the conversation, untick both for its absence, and any scattered subset in between, such as only the user's questions or only system records. A kind is whatever the filter can act on, which is the rule that decided 压缩 being a kind of its own rather than a `system` record with a different title. Filed under `system`, a context checkpoint could not be kept or dropped on its own and unticking 系统 took every checkpoint with it — the same "one question, two answers" problem the layer buttons had, hiding in a second field instead of a second row of controls. The one control that is **not** a filter is 总览轴, in the window bar under the strip: it switches the strip between equal width and a real time axis. It was labelled 时长 and sat with the filters at the top of the page, which made it read as a filter on duration — it changes neither the rows nor any count on the page. Its label now names the strip and both axes, and it sits directly under the thing it changes. -Unticking a kind drops those rows from the render entirely. No placeholder summary row is left behind, so nothing on screen can miscount what it covers, and a chip is the only way to bring them back. The kinds are independent, so unticking one never takes part of another with it, and each turn header recomputes from the rows that survive, which makes every count you see under a filter a real post-filter count. +Unticking a kind drops those rows from the render entirely. No placeholder summary row is left behind, so nothing on screen can miscount what it covers, and a chip is the only way to bring them back. The kinds are independent, so unticking one never takes part of another with it, and each turn header recomputes from the rows that survive, which makes every count you see under a filter a real post-filter count. Nothing marks a turn as filtered: the figures moving is the signal, and a label repeating that on every turn is noise. Values that the session file does not record are rendered as `—`. Nothing is inferred or filled in: a tool call with no matching result has no duration, an assistant message carries no model attribution if the file omitted it, and a message with no Token usage shows no Token usage. -An assistant message is always displayed in one fixed order — **reasoning, then the answer, then the tool calls it asked for** — regardless of the order the blocks appear in the session file. Token usage stays with the answer; a message that produced reasoning but no answer shows its usage on the reasoning block, because that is all the message contains. - -One model call produces one row per block, so a rail down the left edge joins the rows that arrived together — a response split into reasoning, answer and tool calls reads as one unit. A response that produced a single row shows a dot instead. The grouping is also stated in each row's tooltip, so it is never conveyed by position alone. Tool results are not model responses, carry no rail, and are not joined to the call that caused them. +Assistant text arrives in two roles and they are filed separately. **答复** is the answer a turn ended on, and it is not a judgement call: `stopReason` records why the model stopped, and `stop` means it finished, so that text is the delivered answer. **过程** is the text it wrote on the way to a tool call, where `stopReason` is `toolUse`. In an agentic session the two are nowhere near equal — 51 answers against 901 preambles in a typical working session — so filing them together buries the answers under several times their own volume. An assistant message is always displayed in one fixed order — **reasoning, then the answer, then the tool calls it asked for** — regardless of the order the blocks appear in the session file. Token usage stays with the answer; a message that produced reasoning but no answer shows its usage on the reasoning block, because that is all the message contains. + +**Two numbers that look like they should match, and what makes them match.** A turn either ends with +exactly one reply (`stopReason: stop`) or with none — measured over every prompted turn in a +working session, 54 closed, 5 stopped to ask the reader something, 4 were cut off. So the turn +count and the reply count are the same number seen twice, and the KPI card says which each one +means: the headline is the turns somebody typed into, and the line under it splits them into +closed, waiting on the reader, and cut off. Those parts sum to the headline. + +**问答 is a prompted turn that has not closed, not a turn of its own.** It is the turn whose last record +is the "waiting for the local user" result of `ask_user` or `request_feature_enable`. The reader's +answer never reaches the trajectory file as a record — the tool result in the asking turn only ever +says the questionnaire is waiting — so the turn that answers has no prompt of its own and is filed +as a continuation: still conversation, still one reply. An earlier version filed *that* turn as 问答, +which meant a turn could be labelled 问答 while the reader still had not answered, and turns holding +hundreds of tool calls were labelled a Q&A. There is no record and no filter called 问答 for the same +reason: nothing in the file describes one. + +**A message the reader sent while a turn was still running is filed as 追加, apart from 用户.** One turn can +hold several of them — the prompt that opened it, plus whatever arrived while the agent was still +working — and only the first counts as dialogue, so the message count and the turn count stop +disagreeing. Nothing in the file marks it: compared field by field, a 追加 is byte-identical to a typed +prompt in role, `stopReason`, api, model, provider, tokens and `producedBy`. The signal available is +position — measured across every turn that has a prompt, it sits at record 1 or 2 and never further +in — so this is an inference from ordering, not a fact the file asserts, and the detail panel says +so where it shows.One model call produces one row per block, so a rail down the left edge joins the rows that arrived together — a response split into reasoning, answer and tool calls reads as one unit. A response that produced a single row shows a dot instead. The grouping is also stated in each row's tooltip, so it is never conveyed by position alone. Tool results are not model responses, carry no rail, and are not joined to the call that caused them. When a session has been compacted, the runtime does not trim it in place: it rotates the previous `messages.jsonl` into `snapshots/`, opens a new generation, and starts a fresh active file. **The ledger still shows the whole conversation.** The app walks the generation chain the same way the runtime does — it opens the active file, reads the generation that file declares, and steps back one generation at a time through the parent each file names — and then concatenates every reachable generation oldest-first. A snapshot whose parent generation is not exactly one lower ends the chain rather than being stitched on, and a snapshot the chain never reaches (a fork leaves those behind) is reported as an orphan instead of being shown. @@ -87,7 +110,7 @@ The runtime database is opened in read-only mode. If it is missing, unreadable, - The app depends on undocumented internal formats: the `v2/sessions` directory layout and the runtime database schema. A client update can change either, which may break session identification or parsing. - Session identification is an inference, not a binding. With one conversation active it is reliable; with none running it degrades to most-recently-written. - A single `messages.jsonl` is read up to 64 MB. Larger sessions are truncated and the page says so. -- The ledger renders only the newest 200 records by default rather than using true virtual scrolling. A sticky window bar under the overview strip offers four explicit controls: switch the strip between equal width and a real time axis, load 200 earlier records, jump back to the latest 200, and show everything. "Show everything" builds every record in the session into the DOM at once and gets noticeably slower on large sessions; incoming records do not knock it back down to 200. +- The ledger does not use virtual scrolling; every rendered row is a real element. The page therefore opens on the newest 200 records, and the window bar button switches to the whole session. The button names what pressing it will do, not the current state: 「显示全部」 at the default and 「显示最新 200 条」 once everything is showing, while the count beside it reports what is on screen. That full view is opt-in for a reason: on a 9,900-record session it is roughly 57,000 elements rebuilt every 2.5s poll, which was measured to freeze the page rather than be read. 「向上加载更早的 N 条」 widens the window in steps and a poll does not undo it. A sticky window bar under the overview strip offers that choice backers four explicit controls: switch the strip between equal width and a real time axis, load 200 earlier records, jump back to the latest 200, and show everything. "Show everything" builds every record in the session into the DOM at once and gets noticeably slower on large sessions; incoming records do not knock it back down to 200. - `messageCount` and `turnCount` in the session picker are estimates derived from a bounded prefix of the file; exact values come from the selected session. - Light theme token coverage is verified — every `--mcode-*` token the page consumes is defined for both themes — but its rendered appearance was not visually checked; the page was exercised under a dark system preference. diff --git a/plugins/avatasia/mmc-trajectory/README.zh-CN.md b/plugins/avatasia/mmc-trajectory/README.zh-CN.md index 2c07a41..4caabcf 100644 --- a/plugins/avatasia/mmc-trajectory/README.zh-CN.md +++ b/plugins/avatasia/mmc-trajectory/README.zh-CN.md @@ -20,23 +20,36 @@ ## 它展示什么 -台账按 `turn_id` 把记录分组,轮次划分完全依据会话文件中记录的 `turn_id`。每条记录被归类为用户、助手、思考、工具调用、工具结果、系统或压缩;每个轮次表头带该轮自己的 token 计数与耗时。选中任意记录会打开详情面板,含摘要、原始 JSON、工具入参、工具结果与思考内容,并提供一个把该记录内容送回 Agent 对话输入框的按钮。 +台账按 `turn_id` 把记录分组,轮次划分完全依据会话文件中记录的 `turn_id`。每条记录被归类为用户、答复、过程、思考、工具调用、工具结果、系统或压缩;每个轮次表头带该轮自己的 token 计数与耗时。选中任意记录会打开详情面板,含摘要、原始 JSON、工具入参、工具结果与思考内容,并提供一个把该记录内容送回 Agent 对话输入框的按钮。 -**类型选项是唯一的筛选。** 被过滤的始终只有一件事——哪些记录类型可见——客户端里它是同一个数组,只由三个控件写入:六个类型选项、「全部」和「清除筛选」。早期版本在列表上方还排了一行图层按钮(轮次 / 思考 / 调用),作为同一批类型选项的快捷预设,已经删除。任何读者能走偏的预设,最终都会变成一个与它本该概括的选项自相矛盾的按钮:只取消「助手」而保留「用户」,轮次按钮就熄灭并声称整个对话已隐藏,而实际上你只是收窄了它。一个不会跑偏的控件,比三个方便的控件更值钱。 +**类型选项是唯一的筛选。** 被过滤的始终只有一件事——哪些记录类型可见——客户端里它是同一个数组,只由三个控件写入:八个类型按钮、「全部」和「清除筛选」。早期版本在列表上方还排了一行图层按钮(轮次 / 思考 / 调用),作为同一批类型选项的快捷预设,已经删除。任何读者能走偏的预设,最终都会变成一个与它本该概括的选项自相矛盾的按钮:只取消「过程」而保留「用户」,轮次按钮就熄灭并声称整个对话已隐藏,而实际上你只是收窄了它。一个不会跑偏的控件,比三个方便的控件更值钱。 -直接点名类型只多花一次点击,却能到达预设原本覆盖的全部范围:勾选「用户」**和**「助手」就是对话,两个都取消就是没有对话,中间任意散落的子集(只看用户提问、只看系统记录)同样可以。 +直接点名类型只多花一次点击,却能到达预设原本覆盖的全部范围:勾选「用户」**和**「答复」就是对话,两个都取消就是没有对话,中间任意散落的子集(只看用户提问、只看系统记录)同样可以。 **一种类型就是筛选能作用的那个单位。** 这条规则决定了「压缩」为什么是一个独立类型,而不是一条换了标签的「系统」记录:塞在「系统」底下时,一次上下文检查点无法被单独保留或丢弃,取消「系统」还会把所有检查点一起带走 —— 和刚拆掉的图层按钮是同一个「一个问题、两个答案」的毛病,只不过藏在一个第二字段里,而不是藏在一排第二控件里。 **唯一不是筛选的控件是「总览轴」**,它放在总览条正下方的窗口栏里,只切换总览条的横轴。它以前标着「时长」、待在页面顶部的筛选栏旁边,于是被读成了「按时长筛选」—— 它既不改台账里的行,也不改页面上的任何计数。现在它的标签直接写明总览条和两种轴,并待在它真正影响的那条带子下面。 -取消一个类型即不渲染它的行,不留占位摘要行,因此屏幕上没有任何一行需要去概括它本该概括的内容,类型选项也是唯一的恢复途径。各类型彼此独立,取消一个不会顺带走另一个的任何部分;轮次表头按幸存下来的行重算,所以筛选后看到的计数就是筛选后的真实计数。 +取消一个类型即不渲染它的行,不留占位摘要行,因此屏幕上没有任何一行需要去概括它本该概括的内容,类型选项也是唯一的恢复途径。各类型彼此独立,取消一个不会顺带走另一个的任何部分;轮次表头按幸存下来的行重算,所以筛选后看到的计数就是筛选后的真实计数。表头上不再挂「筛选后」徽章 —— 数字自己变了就是信号,每个轮次都重复一遍标签反而是噪音。 会话文件没有记录的值一律渲染为 `—`,不做任何推断或补全:没有匹配结果的工具调用就没有耗时,会话文件没写模型信息的消息就不带模型归属,没有 token 统计的消息就不显示 token。 一条 assistant 消息始终按固定顺序展示 —— **思考、然后回答、然后它发起的工具调用** —— 与这些块在会话文件里的排列顺序无关。token 用量跟着回答走;只产生思考、没有回答的消息,用量显示在思考记录上,因为那就是该消息的全部内容。 -一次模型调用会拆成若干行,左侧的 tree 线把同一次返回的行连在一起 —— 一条被拆成思考、回答、工具调用的响应因此读起来是一个整体;只产生单行的响应则显示为一个圆点。同样的信息也写在每行的悬浮提示里,不会只靠位置表达。工具结果不是模型响应,没有连线,也不与引发它的那次调用相连。 +**两个本来应该对上的数字,以及它们为什么能对上。** 一轮要么以恰好一条答复(`stopReason: stop`)收尾,要么没有; +在一段真在工作的会话里逐轮数下来,54 轮已收尾、5 轮停下来问你、4 轮被切断。所以轮次数和答复数本来就是同一个数字的两种说法, +KPI 卡片里各标清了各自是什么:大数字是你打过字的轮数,下面一行把它拆成已收尾、提问待答、中断。 +这几部分加起来正好是大数字。 + +**问答是一个停下来了、还没收尾的对话轮次,而不是单独的一轮。** 它是最后一条记录为 `ask_user` 或 `request_feature_enable` +的「等待本机用户回复」结果的那一轮。你的回答根本不会作为记录写进轨迹文件——提问那一轮的工具结果只会说「问卷等待中」—— +因此回答的那个轮次本身没有提示词,归为「续作」:还是对话,还是只收尾一次。早期版本把「那个」轮次标成了问答, +结果是你还没回答时就有轮次冒着「问答」,而几百次工具调用的轮次也被标成了一次问答。因为文件里根本没有描述这么一次交换的内容, +所以既没有叫「问答」的记录,也没有叫「问答」的筛选。 + +**你在一轮还没完时发过来的消息单独归为「追加」。** 一轮可以包含多条:打开它的那条提示词,以及助手忙着时你又发来的。只有第一条算对话轮次, +所以消息数和轮次数不再对不上。文件里没有任何标记:逐字段比对过,追加在 role、`stopReason`、api、model、provider、tokens、`producedBy` 上与打字消息完全一致。 +唯一可用的信号是位置:统计每个有提示词的轮次,提示词总在第 1 或第 2 条记录,从不更靠后。所以这是从顺序做的推断,不是文件声明的事实,详情面板在展示它的地方会写明。一次模型调用会拆成若干行,左侧的 tree 线把同一次返回的行连在一起 —— 一条被拆成思考、回答、工具调用的响应因此读起来是一个整体;只产生单行的响应则显示为一个圆点。同样的信息也写在每行的悬浮提示里,不会只靠位置表达。工具结果不是模型响应,没有连线,也不与引发它的那次调用相连。 会话被压缩之后,运行时并不是就地裁剪:它把原来的 `messages.jsonl` 轮转到 `snapshots/`,开启新一代上下文,再新建一份活跃文件。**台账仍然展示完整对话。** 应用用与运行时相同的方式走这条代链 —— 打开活跃文件、读出它自己声明的代号、再沿着每一代各自指名的父代一代一代往回走 —— 然后把每一代按从旧到新的顺序拼接起来。若某一代声明的父代不是恰好小一,代链就在此中断、不会把它接上;代链从未走到的快照(fork 会留下这种)会被当作孤立代报告,而不是展示出来。 @@ -87,7 +100,7 @@ MiniMax Code 不会告诉 Mini App 是哪段对话打开了它的页面:传给 - 本应用依赖未公开的内部格式:`v2/sessions` 目录结构与运行时数据库表结构。客户端一次更新就可能改动其中之一,导致会话识别或解析失效。 - 会话识别是推断,不是绑定。只有一段对话在跑时它很可靠;没有对话在跑时会降级为「最近写入」。 - 单个 `messages.jsonl` 最多读取 64 MB。更大的会话会被截断,页面会明确说明。 -- 台账默认只渲染最新 200 条记录,不是真正的虚拟滚动。总览条正下方的吸顶窗口栏提供四个显式控件:「总览轴」「向上加载更早的 200 条」「只看最新」与「全部显示」。「总览轴」只改总览条的横轴——等宽(每条记录一样宽,完全忽略时间)或真实时间轴(宽度等于该记录的真实耗时);它不筛选、不排序,台账里的行和页面上的任何计数都不变。「全部显示」会把当前会话的全部记录一次性建进 DOM,超大会话会明显变慢;新记录到达时不会把「全部」弹回 200 条。 +- 台里没有用虚拟滚动,每一行都是真实的 DOM 元素。页面默认只展示最新 200 条,窗口条上的按钮可以切到全量;全量是可选的,因为它很贵 —— 在这个会话上约 9,900 条、约 5.7 万个元素,每 2.5 秒轮询重建一次,实测会直接卡死页面。总览条正下方的吸顶窗口栏提供三个显式控件:「总览轴」「向上加载更早的 200 条」,以及在最新 200 条与全量之间切换的那一个按钮(它写的是按下去会变成什么,默认为「显示全部」,全量时为「显示最新 200 条」;当前窗口由右边的计数条报告,两者回答的是不同问题)。「总览轴」只改总览条的横轴——等宽(每条记录一样宽,完全忽略时间)或真实时间轴(宽度等于该记录的真实耗时);它不筛选、不排序,台账里的行和页面上的任何计数都不变。「向上加载更早的 200 条」每点一次就多加一页,轮询不会把已经加宽的窗口重置回去;点切换按钮也不会自动滚动页面,读者正在看的那一行会留在原地。 - 会话选择器里的 `messageCount` / `turnCount` 是从文件的有界前缀估算的;精确值以选中会话后展示的为准。 - 浅色主题的 token 覆盖已验证(页面用到的每个 `--mcode-*` 变量在两套主题下都有定义),但渲染外观未做视觉检查;功能验收是在深色系统偏好下进行的。 diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index e68217b..27417a1 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -225,6 +225,17 @@ transition: background-color 150ms ease-out, border-color 150ms ease-out, color 150ms ease-out; } + /* `display:inline-flex` above outranks the user-agent [hidden] rule, so every `.btn` that + is toggled off with .hidden stayed on screen. The one that showed it is 「向上加载更早的 + N 条」 in the window bar: its label is only written when there is something earlier to + load, so hiding it with no label to erase left a bordered empty button sitting between + the axis toggle and the window button. Same defect as .overview-clear below, and the + same fix — patched here on the class rather than on one instance, because every `.btn` + in this file can be toggled the same way. */ + .btn[hidden] { + display: none; + } + .btn:hover:not(:disabled) { background: var(--mcode-surface-muted); border-color: var(--mcode-border-strong); @@ -478,12 +489,6 @@ outline-offset: 2px; } - .toolbar-status { - margin: 0; - font-size: 12px; - color: var(--mcode-text-muted); - } - /* ---------------- KPI strip ---------------- */ .kpi-section { @@ -561,13 +566,136 @@ overflow-wrap: anywhere; } + /* The 对话轮次 card is the only one whose sub-line cannot be read off it — 收尾 + + 提问轮 + 中断 has to be understood before it means anything. So it explains itself. + Driven by CSS, not JS: there is no show/hide call to leave stuck open if a render + lands mid-hover, and `:focus-visible` gives a keyboard the same thing a mouse gets. + `aria-describedby` names the very same node, so the explanation reaches a screen + reader whether or not anyone hovers anything. */ + .kpi-card.has-tip { + position: relative; + cursor: help; + } + + .kpi-tip { + position: absolute; + z-index: 6; + top: calc(100% + 4px); + left: 0; + width: max-content; + max-width: 300px; + padding: 8px 10px; + border: 1px solid var(--mcode-border-strong); + border-radius: 6px; + background: var(--mcode-surface); + color: var(--mcode-text); + font-size: 11px; + line-height: 1.6; + text-align: left; + /* opacity, not visibility: a hidden-from-display node drops out of the accessibility + tree, and this node is the aria-describedby target. */ + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; + } + + .kpi-card.has-tip:hover .kpi-tip, + .kpi-card.has-tip:focus-visible .kpi-tip, + .kpi-card.has-tip:focus-within .kpi-tip { + opacity: 1; + } + + .kpi-tip-line + .kpi-tip-line { + margin-top: 5px; + } + + .kpi-tip-line.is-head { + color: var(--mcode-text-muted); + } + + /* 追加 and 压缩 on this line count RECORDS, and they are the same numbers the kind + chips filter — so they are spelled as the chips, inline. They go through the one + writer (onKindToggle) and say the same thing in the same words, so the number here + and the chip cannot drift apart: that would be two filters with two opinions again. + The four turn-outcome terms are a different axis and stay plain text. */ + .kpi-link { + font: inherit; + color: inherit; + padding: 0 2px; + margin: 0; + border: 0; + border-radius: 3px; + background: none; + cursor: pointer; + text-decoration: underline dotted; + text-underline-offset: 3px; + } + + .kpi-link[aria-pressed='false'] { + color: var(--mcode-text-subtle); + text-decoration: line-through; + } + + .kpi-link:hover { + background: var(--mcode-surface-muted); + color: var(--mcode-text); + } + + .kpi-link:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; + } + .kpi-card.is-skeleton .kpi-value { color: var(--mcode-text-subtle); } - /* ---------------- view control (dsh TrajectoryToolbar) ---------------- - Dimensions and states copied from the reference toolbar: a 20px control, - 12px label, no fill until hovered or pressed. */ + /* ---------------- kind filters (dsh TrajectoryToolbar) ---------------- + Copied from the reference toolbar: a 20px control, 12px label, no fill until + hovered, and a code-font ⊟/⊞ glyph that says at a glance whether the kind is + on. The reference styles the glyph rather than the button, so the button stays + flat when pressed and only the mark changes. */ + + .traj-controls { + position: sticky; + top: 0; + z-index: 5; + display: flex; + flex: none; + align-items: center; + padding: 6px 16px; + background: var(--mcode-bg); + border-bottom: 1px solid var(--mcode-border); + } + + .traj-controls-actions { + display: flex; + flex: none; + align-items: center; + gap: 2px; + } + + .tbtn-action { + padding: 0 5px; + } + + .tbtn-glyph { + flex: none; + color: var(--mcode-text-muted); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 14px; + line-height: 14px; + } + + /* 「全部」 is the one control that is not about a single kind, so it leads the row + and carries a divider rather than a ⊟/⊞ — its state is "are they all on", which + the per-kind buttons already show one by one. */ + .tbtn-all { + margin-right: 6px; + padding-left: 8px; + border-right: 1px solid var(--mcode-border); + border-radius: 0; + } .tbtn { display: inline-flex; @@ -599,6 +727,8 @@ outline-offset: 2px; } + /* Filled when pressed, because this one button *is* the setting it reports. The + kind buttons opt out of that fill below — see .tbtn-action. */ .tbtn[aria-pressed='true'] { color: var(--mcode-accent); background: var(--mcode-selection); @@ -606,6 +736,22 @@ font-weight: 600; } + /* COLOR override moved below on purpose: .tbtn-action[aria-pressed='true'] and + .tbtn[aria-pressed='true'] have the same specificity (0,2,0), so source order + decides. Declared above the generic rule, the kind buttons lost to it and + rendered as blue filled chips. Keep this block after that one. */ + .tbtn-action[aria-pressed='true'] { + color: var(--mcode-text); + background: transparent; + box-shadow: none; + font-weight: 600; + } + + .tbtn-action[aria-pressed='true'] .tbtn-glyph { + color: var(--mcode-text); + } + + .tbtn-icon { flex: none; width: 12px; @@ -620,7 +766,7 @@ .overview { position: sticky; - top: 0; + top: 32px; z-index: 4; border-bottom: 1px solid var(--mcode-border); user-select: none; @@ -711,7 +857,8 @@ .ov-span[data-kind='user'] { background: var(--mcode-accent); opacity: 1; } - .ov-span[data-kind='assistant'], + .ov-span[data-kind='reply'], + .ov-span[data-kind='narration'], .ov-span[data-kind='thinking'] { background: var(--mcode-text); opacity: 1; @@ -1080,17 +1227,32 @@ flex: none; } + .pill-steer { + color: var(--mcode-text-muted); + border-color: currentColor; + border-style: dashed; + background: var(--mcode-surface-muted); + } .pill-user { color: var(--mcode-accent); border-color: currentColor; background: var(--mcode-surface-muted); } - .pill-assistant { + /* 问答 keeps the prominent treatment its 助手 ancestor had: it is the record a + reader opened the page for. */ + .pill-reply { color: var(--mcode-success); border-color: currentColor; } + /* 过程 is the other 17x volume — preamble written on the way to a tool call — so it + reads as supporting detail, in the same family as 工具结果 and 系统. */ + .pill-narration { + color: var(--mcode-text-muted); + border-style: dashed; + } + .pill-thinking { color: var(--mcode-text-muted); } @@ -1150,10 +1312,11 @@ .window-bar { position: sticky; - /* Sticky offsets stack here: the overview strip owns 0–51 (the 50px plot plus its 1px - border), so a bar pinned at 0 would simply hide behind it. z-index 3 keeps the bar - under the overview (4) and over the turn head (2). */ - top: 51px; + /* Sticky offsets stack here: the type row owns 0–32 and the overview strip owns + 32–83 (32 + the 50px plot + its 1px border), so a bar pinned at 0 would simply + hide behind both of them. z-index 3 keeps it under the overview (4) and over the + turn head (2). */ + top: 83px; z-index: 3; padding: 8px 16px; background: var(--mcode-bg); @@ -1562,7 +1725,7 @@

会话轨迹

- +
-
@@ -1631,6 +1752,60 @@

关键指标

+ +

轨迹总览

@@ -1697,7 +1872,7 @@

检视

- +
@@ -1712,7 +1887,9 @@

检视

var KIND_LABELS = { user: '用户', - assistant: '助手', + steer: '追加', + reply: '答复', + narration: '过程', thinking: '思考', toolCall: '工具调用', toolResult: '工具结果', @@ -1723,7 +1900,7 @@

检视

// 压缩 is last on purpose. It is written by the runtime like 系统 is, but it is a // context checkpoint rather than a note about the run, and putting it next to 系统 // would invite reading the two as one category. - var KIND_ORDER = ['user', 'assistant', 'thinking', 'toolCall', 'toolResult', 'system', 'compaction']; + var KIND_ORDER = ['user', 'steer', 'reply', 'narration', 'thinking', 'toolCall', 'toolResult', 'system', 'compaction']; // The type chips are the only filter. Which record kinds are visible is one array — // state.kinds — and every control that changes it writes to that array and nowhere @@ -2025,7 +2202,7 @@

检视

lines.push('- 会话:' + (session.label ? session.label + '(' + session.id + ')' : session.id)); } var turnIndex = turn && isCount(turn.index) ? turn.index : null; - lines.push('- 轮次:' + (turnIndex === null ? DASH : '轮次 ' + turnIndex)); + lines.push('- 轮次:' + (turnIndex === null ? DASH : '第 ' + turnIndex + ' 轮')); lines.push('- 记录:#' + (isCount(record.index) ? record.index : DASH) + ' · ' + kindLabel(record)); if (record.role) lines.push('- 角色:' + record.role); if (record.toolName) lines.push('- 工具:' + record.toolName + (record.toolCallId ? '(' + record.toolCallId + ')' : '')); @@ -2148,6 +2325,10 @@

检视

var POLL_INTERVAL = 2500; var PAGE_STEP = 200; var SEARCH_DEBOUNCE = 200; + // Which window the page opens on, and which every reset returns to. A constant rather + // than a literal at the eight call sites, so that changing the default cannot leave half + // the resets quoting the old one — the failure that had to be fixed once already. + var DEFAULT_WINDOW_MODE = 'latest'; // Declared here, with the other constants, because `state` below reads it on the // very first line of initialisation. A `var` further down the same scope is // hoisted but still `undefined` at that moment, so the read would silently @@ -2192,6 +2373,12 @@

检视

// re-pin the window to the newest page, which would have thrown 「全部」 back to 200 // the instant a record landed. This flag is what says "the reader asked for all of // it", so a growing session extends the view instead of cancelling it. + // + // It opens OFF, and that is not a shortcut: 全部 renders every record of the session as + // real DOM — around 9,900 rows and 57,000 elements on this one — with no virtual + // scrolling, rebuilt on every 2.5s poll. On load that froze the page outright (a single + // inspect took 13.7s, a click 5.4s), which was measured, not estimated. So 全部 is + // something a reader asks for and can live with, not something the page does to them. allRecords: false, activeTab: 'summary', hostSessionId: detectHostSessionId(), @@ -2222,14 +2409,13 @@

检视

dom.errorText = document.getElementById('error-text'); dom.staleBar = document.getElementById('stale-bar'); dom.toolbar = document.getElementById('toolbar'); + dom.kindControls = document.getElementById('kind-controls'); dom.sessionSelect = document.getElementById('session-select'); dom.generationField = document.getElementById('generation-field'); dom.generationSelect = document.getElementById('generation-select'); dom.generationHint = document.getElementById('generation-hint'); dom.searchInput = document.getElementById('search-input'); dom.windowBar = document.getElementById('window-bar'); - dom.chipAll = document.getElementById('chip-all'); - dom.filterStatus = document.getElementById('filter-status'); dom.headerToggle = document.getElementById('header-toggle'); dom.kpiToggle = document.getElementById('kpi-toggle'); dom.kpiGrid = document.getElementById('kpi-grid'); @@ -2477,23 +2663,171 @@

检视

/* ---------------- rendering: KPI ---------------- */ - function kpiCard(label, value, sub, skeleton) { + /** + * One KPI card. `sub` may be a string or a node, because 对话轮次 needs its line to carry + * two live controls. `tip` is an array of lines rendered into the card and named by + * aria-describedby; pass tipId to keep that id stable across renders. + */ + function kpiCard(label, value, sub, skeleton, tipId, tip) { var card = el('div', 'kpi-card' + (skeleton ? ' is-skeleton' : '')); + var explained = Boolean(tipId && tip && tip.length); + if (explained) { + card.classList.add('has-tip'); + card.setAttribute('tabindex', '0'); + card.setAttribute('aria-describedby', tipId); + } card.appendChild(el('span', 'kpi-label', label)); card.appendChild(el('span', 'kpi-value', value)); - card.appendChild(el('span', 'kpi-sub', sub)); + card.appendChild(typeof sub === 'string' ? el('span', 'kpi-sub', sub) : sub); + if (explained) { + var tipNode = el('div', 'kpi-tip'); + tipNode.id = tipId; + tipNode.setAttribute('role', 'tooltip'); + for (var i = 0; i < tip.length; i += 1) { + tipNode.appendChild(el('p', 'kpi-tip-line' + (i === 0 ? ' is-head' : ''), tip[i])); + } + card.appendChild(tipNode); + } return card; } - // One label per part of a turn total, in the order a reader would count them. + /** + * How each conversation turn ended. These three sum to the headline by construction. + * + * The reason 收尾 and 提问轮 are separate numbers, rather than one 已收尾: a turn that + * stops to ask the reader does not lose its answer. Measured over every turn that asked, + * all of them were answered — but ask_user ends the turn, so the answer arrives in the + * *next* turn, which carries no prompt of its own. 收尾 is 收尾 because the reply is inside + * it; 提问轮 is a turn whose reply sits in its successor. Those two add up to the 答复 + * count exactly, which is why the reply count can be read off this line. + * + * 中断 is the only part with no reply anywhere: cut off at a compaction, or still running. + */ function turnBreakdown(stats) { - var parts = ['对话 ' + formatInt(stats.turnsWithPrompt)]; - if (isCount(stats.qaTurns) && stats.qaTurns > 0) parts.push('问答 ' + formatInt(stats.qaTurns)); - if (isCount(stats.compactionTurns) && stats.compactionTurns > 0) parts.push('压缩 ' + formatInt(stats.compactionTurns)); - if (isCount(stats.turnsWithoutPrompt) && stats.turnsWithoutPrompt > 0) parts.push('其他 ' + formatInt(stats.turnsWithoutPrompt)); + var total = isCount(stats.turnsWithPrompt) ? stats.turnsWithPrompt : 0; + var asked = isCount(stats.qaTurns) ? stats.qaTurns : 0; + var open = isCount(stats.openTurns) ? stats.openTurns : 0; + var parts = [ + '收尾 ' + formatInt(Math.max(0, total - open)), + '提问轮 ' + formatInt(asked) + ]; + if (open > asked) parts.push('中断 ' + formatInt(open - asked)); return parts.join(' · '); } + /** + * What the turn count leaves out — a DIFFERENT population, not more of the same one. + * A checkpoint is not a conversation and a continuation is not a new one. + * + * This line used to sit in the same `·`-separated run as the breakdown, which put two + * numbers worth 5 side by side: 续作答答 (the turns that stopped to ask) and 续作 (the + * prompt-less turns that carried the answers). They are disjoint — overlap measured 0 — + * but read as one list they look like the same five counted twice. 另有 marks the + * boundary so the second group cannot be added to the first by eye. + */ + function turnContext(stats) { + return turnContextParts(stats).map(function (p) { + return p.label + ' ' + p.value; + }).join(' · '); + } + + /** + * The out-of-headline terms, each tagged with the kind it stands for — or null when it + * counts turns rather than records. 追加 is the steer records and 压缩 is the compaction + * records, so those two are literally what the chips filter. The rest count turns, which + * no kind filter can express: a 收尾 turn holds eight different kinds at once. + */ + function turnContextParts(stats) { + var parts = []; + if (isCount(stats.steers) && stats.steers > 0) { + parts.push({ label: '追加', value: formatInt(stats.steers), kind: 'steer' }); + } + if (isCount(stats.compactionTurns) && stats.compactionTurns > 0) { + parts.push({ label: '压缩', value: formatInt(stats.compactionTurns), kind: 'compaction' }); + } + if (isCount(stats.continuationTurns) && stats.continuationTurns > 0) { + parts.push({ label: '续作轮', value: formatInt(stats.continuationTurns), kind: null }); + } + return parts; + } + + function turnContextSuffix(stats) { + var context = turnContext(stats); + return context ? ' · 另有 ' + context : ''; + } + + /** + * The same line, with the two record-counting terms as live controls. Pressed state is + * read from state.kinds, so it is the same state the chips show and cannot disagree; + * the accessible name is the chip's, word for word. + */ + function turnSubNode(stats) { + var wrap = el('span', 'kpi-sub'); + wrap.appendChild(document.createTextNode(turnBreakdown(stats))); + var parts = turnContextParts(stats); + if (!parts.length) return wrap; + wrap.appendChild(document.createTextNode(' · 另有 ')); + for (var i = 0; i < parts.length; i += 1) { + if (i > 0) wrap.appendChild(document.createTextNode(' · ')); + var part = parts[i]; + if (!part.kind) { + wrap.appendChild(document.createTextNode(part.label + ' ' + part.value)); + continue; + } + var on = state.kinds.indexOf(part.kind) >= 0; + var noun = KIND_LABELS[part.kind] || part.label; + var button = el('button', 'kpi-link', part.label + ' ' + part.value); + button.type = 'button'; + button.setAttribute('data-kind', part.kind); + button.setAttribute('aria-pressed', on ? 'true' : 'false'); + var name = noun + (on ? '记录当前显示 · 点击隐藏' : '记录当前已隐藏 · 点击显示'); + button.setAttribute('aria-label', name); + button.setAttribute('title', name); + button.addEventListener('click', function (event) { + var kind = event.currentTarget.getAttribute('data-kind'); + // Read state.kinds at click time rather than closing over `on`: this card is + // re-rendered on a poll, and a captured flag would go stale between the render + // and the click — the symptom being a link that looks off but filters on, or the + // reverse. state.kinds is the one place the answer lives. + onKindToggle(kind, state.kinds.indexOf(kind) < 0); + }); + wrap.appendChild(button); + } + return wrap; + } + + /** What the 对话轮次 line cannot say by looking at it. Kept to six lines: it opens under the + * card and covers the kind chips, so every extra line hides the thing it points at. */ + function turnTipLines(stats) { + var total = isCount(stats.turnsWithPrompt) ? stats.turnsWithPrompt : 0; + var asked = isCount(stats.qaTurns) ? stats.qaTurns : 0; + var open = isCount(stats.openTurns) ? stats.openTurns : 0; + return [ + '对话轮次 = 首条是你打字消息的轮次数', + '收尾 ' + formatInt(Math.max(0, total - open)) + ' · 答复在本轮内交付', + '提问轮 ' + formatInt(asked) + ' · 停下来问你了,答复落在下一轮', + '中断 ' + formatInt(Math.max(0, open - asked)) + ' · 既没答复也没提问,唯一真缺答复的', + '三个相加 = 大数字;答复数 = 大数字 − 中断', + '另有 · 追加与压缩是记录数,点它等于点同名标签;续作轮是提问轮的答复所在' + ]; + } + + /** + * The panel title describes the table and nothing else: 「第 82–84 轮」 is the range of + * turns actually rendered below it, so it can never contradict the first header. + * + * The session total deliberately does NOT live here. Putting 「共 84 轮」 next to a table + * that starts at 第 82 轮 invited exactly the reading "these should be equal" — and they + * are not, because the default window holds only the newest 200 records and one of those + * turns alone holds 183 of them. The total is still on screen, on the window bar beside + * how many records are not loaded, which is the question it actually answers. + */ + function turnRangeLabel(firstTurn, lastTurn) { + if (!isCount(firstTurn) || !isCount(lastTurn)) return DASH; + if (firstTurn === lastTurn) return '第 ' + formatInt(firstTurn) + ' 轮'; + return '第 ' + formatInt(firstTurn) + '–' + formatInt(lastTurn) + ' 轮'; + } + function renderKpi() { var grid = dom.kpiGrid; clear(grid); @@ -2532,7 +2866,8 @@

检视

kpiCard( '消息', formatInt(stats.messages), - '用户 ' + formatInt(stats.userMessages) + ' · 助手 ' + formatInt(stats.assistantMessages) + + '用户 ' + formatInt(stats.userMessages) + + (isCount(stats.steers) && stats.steers > 0 ? ' · 追加 ' + formatInt(stats.steers) : '') + ' · 思考块 ' + formatInt(stats.thinkingBlocks) + compactionNote + rotationNote ) ); @@ -2547,12 +2882,17 @@

检视

grid.appendChild( kpiCard( '对话轮次', - formatInt(stats.turns), - // Spell out what the total is made of. A raw turn count is the number most likely - // to be misread as "how many things I typed": a checkpoint is not conversation, - // and a reply that arrived through a question card never became a typed message. - // Only the parts that are actually present are listed. - turnBreakdown(stats) + formatInt(isCount(stats.turnsWithPrompt) ? stats.turnsWithPrompt : 0), + // The headline is the number of turns the reader typed into — not every turn_id + // group in the file, which also holds checkpoints and the continuation that + // follows each question. The first group says how each one ended and sums to the + // number above; 另有 then starts a different population, the turns the headline + // leaves out, which must never be added to the first group. The two terms that + // count records are the kind chips, inline and live. + turnSubNode(stats), + false, + 'kpi-tip-turns', + turnTipLines(stats) ) ); grid.appendChild( @@ -2628,7 +2968,7 @@

检视

function overviewLane(record) { var kind = record && record.kind; if (kind === 'toolCall' || kind === 'toolResult') return 2; - if (kind === 'assistant' || kind === 'thinking') return 1; + if (kind === 'reply' || kind === 'narration' || kind === 'thinking') return 1; return 0; } @@ -2656,7 +2996,7 @@

检视

var duration = isCount(record.durationMs) ? Math.max(0, record.durationMs) : 0; return { start: timestamp, end: timestamp + duration, measured: duration > 0 }; } - if (record.kind === 'assistant' || record.kind === 'thinking') { + if (record.kind === 'reply' || record.kind === 'narration' || record.kind === 'thinking') { var from = prevTimestamp === null || prevTimestamp >= timestamp ? timestamp : prevTimestamp; return { start: from, end: timestamp, measured: from !== timestamp }; } @@ -2883,7 +3223,7 @@

检视

abortOverviewDrag(); state.overviewSelection = null; state.overviewViewport = null; - setWindowMode('latest'); + resetWindowMode(); renderOverview(); renderLedger(); } @@ -2896,7 +3236,7 @@

检视

// in the other. Reset rather than reinterpret. state.overviewSelection = null; state.overviewViewport = null; - setWindowMode('latest'); + resetWindowMode(); renderOverview(); renderLedger(); } @@ -2955,6 +3295,7 @@

检视

dom.headerToggle.setAttribute('aria-label', filterLabel); dom.headerToggle.setAttribute('title', filterLabel); dom.toolbar.hidden = !filtersOpen; + dom.kindControls.hidden = !filtersOpen; var kpiOpen = !state.kpiCollapsed; dom.kpiToggle.setAttribute('aria-expanded', kpiOpen ? 'true' : 'false'); @@ -3197,7 +3538,7 @@

检视

end = center + minimum / 2; } state.overviewSelection = { start: start, end: end }; - setWindowMode('latest'); + resetWindowMode(); renderOverviewSelection(); renderLedger(); } @@ -3392,7 +3733,9 @@

检视

function kindTone(kind) { if (kind === 'user') return 'user'; - if (kind === 'assistant') return 'assistant'; + if (kind === 'steer') return 'steer'; + if (kind === 'reply') return 'reply'; + if (kind === 'narration') return 'narration'; if (kind === 'thinking') return 'thinking'; if (kind === 'toolCall') return 'toolcall'; if (kind === 'toolResult') return 'toolresult'; @@ -3458,7 +3801,7 @@

检视

// control that does nothing is worse than no control. var head = el('div', 'turn-head turn-head-static'); - var title = '轮次 ' + (isCount(turn.index) ? turn.index : DASH); + var title = '第 ' + (isCount(turn.index) ? turn.index : DASH) + ' 轮'; head.appendChild(el('span', 'turn-title', title)); // Under a filter, describe the visible rows; otherwise trust the server's @@ -3471,7 +3814,9 @@

检视

var durationMs = filtered ? totals.durationMs : turn.durationMs; var meta = el('span', 'turn-meta'); - if (filtered) meta.appendChild(el('span', 'note', '筛选后')); + // No "filtered" badge here. Under a filter the numbers themselves already change — + // a tool-call count that drops out of a row is the signal, and a label repeating it + // on every single turn is the noise. // A filtered turn with no tool work left has no tool-call figure to print; // showing a column of zeroes beside every row would read as data, not absence. if (!filtered || toolCalls > 0) { @@ -3527,11 +3872,16 @@

检视

/** * How much of the session the ledger shows. * - * 'latest' keeps the newest PAGE_STEP records and follows the tail; 'all' drops the - * cap so the growing session simply extends the view. Anything that changes what the - * numbers mean — a filter, a new session, a fresh search, a brush — resets to - * 'latest', because a stale "show everything" from before the filter would otherwise - * reappear holding records the reader had just excluded. + * 'all' drops the cap so the whole session renders and a growing one simply extends the + * view; 'latest' keeps the newest PAGE_STEP records. It opens on 'all' — see the `state` + * field — so the default lives in one place and every reset below quotes it rather than + * naming a mode, which is how a flipped default silently leaves half the resets behind. + * + * Anything that changes what the numbers mean — a filter, a new session, a fresh search, + * a brush, a generation pin — resets to the default. Resetting is not about shrinking the + * window: it is about discarding a window that was chosen for a set of records which no + * longer exists. A reader who was looking at only the newest 200 and then brushes an + * older range must not be left with a 200-row cap over a range holding thousands. * * @param {'latest'|'all'} mode */ @@ -3540,18 +3890,38 @@

检视

state.limit = state.allRecords ? Infinity : PAGE_STEP; } + /** + * Back to whatever the page opens on. + * + * It must not read `state.allRecords`. A reset is by definition called *because* the + * current window is about to become meaningless, so asking the current window what to + * reset to would preserve exactly the value that has to be discarded. The default is a + * constant, and every reset quotes it — which is also why flipping the default is one + * edit here rather than seven scattered across the file. + */ + function resetWindowMode() { + setWindowMode(DEFAULT_WINDOW_MODE); + } + /** * What happens to the window when a poll brings new records. * - * Only the tail-following window re-pins to the newest page — that is what makes it - * follow. 「全部」 must not re-pin, or the first record to arrive after the click - * would cancel it, and the reader would be thrown back to 200 records without ever - * being told why. + * 全部 must not re-pin, or the first record to arrive after the click would cancel it + * and the reader would be thrown back to 200 records without ever being told why. + * + * 「向上加载更早的 N 条」 must not re-pin either, and that is the older of the two. + * `limit` is the reader's own choice, not a cache: at exactly PAGE_STEP the window is + * following the tail and re-pinning is what makes it follow, but above PAGE_STEP the + * reader pressed a button asking for more, and a poll that resets the limit takes that + * back without saying so. The button re-rendered and appeared to do nothing — the only + * reason it was not noticed is that this session grows by a few records per second, so + * the count stayed near 200 either way. Growing the session hid a dead control. * * @returns {number} the limit now in force */ function rePinWindow() { if (state.allRecords) return state.limit; + if (state.limit > PAGE_STEP) return state.limit; state.limit = Math.min(PAGE_STEP, state.flat.length) || PAGE_STEP; return state.limit; } @@ -3597,14 +3967,13 @@

检视

) ); dom.ledgerMeta.textContent = '共 0 条'; - updateFilterStatus(0, 0); + applyFilterScope(); return; } var matched = visibleEntries(); var total = state.flat.length; - updateFilterStatus(matched.length, total); - dom.ledgerMeta.textContent = '轮次 ' + (state.payload.stats ? formatInt(state.payload.stats.turns) : DASH); + applyFilterScope(); if (matched.length === 0) { var reset = el('button', 'btn', '清除筛选'); @@ -3624,10 +3993,20 @@

检视

updateWindowBar(windowed, hiddenAbove, total); var groups = visibleTurns(windowed); + + // The title states the total, and — when the window does not start at the first turn — + // the range that is actually on screen. Without the range, a title of 「共 83 轮」 above + // a table whose first header reads 「第 81 轮」 looks like the total is wrong or that + // turns 1–80 vanished. Both numbers were right; the window simply starts mid-session, + // and this is the only place that can say so. + var firstTurn = groups.length ? groups[0].turn.index : null; + var lastTurn = groups.length ? groups[groups.length - 1].turn.index : null; + dom.ledgerMeta.textContent = turnRangeLabel(firstTurn, lastTurn); + for (var i = 0; i < groups.length; i += 1) { var group = groups[i]; var section = el('section', 'turn-group'); - section.setAttribute('aria-label', '轮次 ' + (isCount(group.turn.index) ? group.turn.index : DASH)); + section.setAttribute('aria-label', '第 ' + (isCount(group.turn.index) ? group.turn.index : DASH) + ' 轮'); section.appendChild(buildTurnHead(group.turn, group.entries)); var list = el('ul', 'row-list'); // Every entry that survived the filters becomes a row. There is no second pass @@ -3675,9 +4054,13 @@

检视

applyAxisButton(parts.axis); applyWindowMode(parts, hiddenAbove); + // The session's total turn count lives here, beside the record counts it explains — + // not in the panel title, which describes only the rows on screen. + var sessionTurns = state.payload && state.payload.stats ? state.payload.stats.turns : null; parts.count.textContent = - '已显示 ' + formatInt(shown.length) + ' / 共 ' + formatInt(total) + ' 条记录' + - (hiddenAbove > 0 ? '(更早的 ' + formatInt(hiddenAbove) + ' 条尚未加载)' : ''); + (isCount(sessionTurns) ? '全场 ' + formatInt(sessionTurns) + ' 轮 · ' : '') + + '共 ' + formatInt(total) + ' 条 · 已显示 ' + formatInt(shown.length) + ' 条' + + (hiddenAbove > 0 ? ' · 更早的 ' + formatInt(hiddenAbove) + ' 条未加载' : ''); } /** One-time construction. Every handler is attached here and never moves again. */ @@ -3701,58 +4084,105 @@

检视

// No scroll correction here on purpose: renderLedger anchors on the topmost // visible row and puts it back, so adding a second adjustment here would // double-count the shift and leave the page visibly off. + // + // Guarded, because this button is meaningless in 全部: state.limit is Infinity there, + // and Infinity + 200 is still Infinity, so the click would re-render and change + // nothing — a control that looks live and is not. It is hidden in that mode, so the + // branch is unreachable through the UI; the guard is there so that hiding it can + // never be the only thing standing between a reader and a button that does nothing. + if (state.allRecords) return; state.limit += PAGE_STEP; renderLedger(); }); actions.appendChild(more); - var latest = el('button', 'btn btn-quiet'); - latest.type = 'button'; - latest.addEventListener('click', function () { - setWindowMode('latest'); + // One button for the one choice. It was two — 「只看最新 200 条」 and 「已显示全部」 — + // and the first one relabelled itself to the second one's wording once you were + // already showing everything, which made the row read as three controls for two + // states. The label names the state you are in; the accessible name names the action. + // + // Clicking it must not move the page. It used to scroll to the tail whenever it + // narrowed the window, on the theory that "back to the tail" should land on the newest + // record. But the reader clicked a control in a bar they were already looking at, so + // the one thing they did not ask for is the viewport jumping thousands of pixels: the + // row under the cursor changed and everything they had been reading left the screen. + // renderLedger already anchors on the topmost visible row and puts it back, which is + // the correct behaviour here — the anchor is the row the reader was on, not the newest + // record in the session. + var mode = el('button', 'btn'); + mode.type = 'button'; + mode.addEventListener('click', function () { + setWindowMode(state.allRecords ? 'latest' : 'all'); renderLedger(); - // Back to the tail means back to the tail: land on the newest record rather - // than wherever the 4,000-row view happened to leave the reader. - var doc = document.documentElement; - window.scrollTo(0, Math.max(doc.scrollHeight, document.body.scrollHeight)); }); - actions.appendChild(latest); - - var all = el('button', 'btn'); - all.type = 'button'; - all.addEventListener('click', function () { - setWindowMode('all'); - renderLedger(); - }); - actions.appendChild(all); + actions.appendChild(mode); // Count last, pushed to the far edge. Sitting it between the buttons made the row // wrap on any session with a five-figure record count. var count = el('span', 'window-bar-count'); + count.id = 'window-count'; + count.setAttribute('role', 'status'); actions.appendChild(count); - return { actions: actions, axis: axis, more: more, latest: latest, all: all, count: count }; + return { actions: actions, axis: axis, more: more, mode: mode, count: count }; } /** - * 「只看最新」 and 「全部显示」 are each other's inverse, so exactly one is offered at - * any moment, and the other is stated as the way back. Reading the same button as - * both would make its pressed state mean "whichever is currently not true". + * The mode button names the action; the count beside it names the state. + * + * They are not saying the same thing twice, and they must not be made to. The count is + * the report — 已显示 200 条, 更早的 9,783 条未加载 — and it is the only thing on the row + * that tells you what you are looking at. The button is the offer, and what it offers is + * by definition not what you already have: from the newest 200, the whole session; from + * everything, back to one page. A button that restates the current state is a button + * whose text changes to tell you nothing new. + * + * It was briefly written the other way and read as a contradiction, because at the time + * the label and the count were describing the same quantity with different wording — + * 「只看最新 200 条」 above 「已显示 200 条」. Naming the action dissolves that rather than + * causing it: the two now answer different questions, and neither can contradict the + * other because neither claims to be the other. + * + * 「显示全部」 and 「显示最新 N 条」 are each other's inverse, so exactly one is offered at + * any moment and the other is the way back. */ function applyWindowMode(parts, hiddenAbove) { parts.more.hidden = hiddenAbove <= 0; - if (hiddenAbove > 0) { - parts.more.textContent = '向上加载更早的 ' + formatInt(Math.min(PAGE_STEP, hiddenAbove)) + ' 条'; - } - parts.latest.textContent = state.allRecords - ? '已显示全部 · 点此只看最新' - : '只看最新 ' + formatInt(PAGE_STEP) + ' 条'; - parts.latest.setAttribute('aria-pressed', state.allRecords ? 'false' : 'true'); - parts.latest.classList.toggle('btn-quiet', !state.allRecords); - parts.all.textContent = '已显示全部'; - parts.all.setAttribute('aria-pressed', state.allRecords ? 'true' : 'false'); - parts.all.disabled = state.allRecords; - parts.all.classList.toggle('btn-quiet', !state.allRecords); + // The label is written unconditionally, not only when the button is showing. Its text + // is the only record of what the button would do, and a `.btn` carrying `hidden` but no + // label is an empty bordered box — which is exactly what this button was on load, for + // one extra reason: [hidden] was losing to `display:inline-flex` (see .btn[hidden]). + // Writing it always means the button cannot render blank even if it is ever shown by + // something other than this function. + parts.more.textContent = '向上加载更早的 ' + formatInt(Math.min(PAGE_STEP, Math.max(hiddenAbove, 0))) + ' 条'; + // The button names the ACTION: it says what pressing it will do, which is the only + // information the reader does not already have. The state is already on screen — the + // count beside this button reports 已显示 200 条 or 已显示 9,977 条, and the page title + // says which turns that covers — so a label restating it spends its width saying + // nothing. At the default the window already holds only the newest 200, so the one + // thing worth offering is the whole session; once everything is shown, the one thing + // worth offering is going back to a page. + // + // It went the other way twice. Describing the state put 「只看最新 200 条」 next to + // 「已显示 200 条」 — the button echoing the count beside it. Describing the state after + // a second change put 「已显示全部」 on a page showing everything, which is true and + // therefore tells you nothing about the button. Naming the action is what both cases + // need. + parts.mode.textContent = state.allRecords ? '显示最新 ' + formatInt(PAGE_STEP) + ' 条' : '显示全部'; + // aria-pressed describes the STATE, not the action: pressed means "the whole session is + // showing". The visible label says what pressing it will do, which is the opposite + // tense, and that is normal — it is why the accessible name above repeats the state. + parts.mode.setAttribute('aria-pressed', state.allRecords ? 'true' : 'false'); + // Quiet while showing everything: there is nothing to reveal, and the one offer left + // is a step back. + parts.mode.classList.toggle('btn-quiet', state.allRecords); + // The accessible name carries the state, since the action is already the visible text + // and a screen reader reads the label first. It is what the button is *pressed* as, so + // naming it is also what makes aria-pressed mean something. + var modeLabel = state.allRecords ? '正在显示全部记录 · 点击改为只显示最新 ' + formatInt(PAGE_STEP) + ' 条' + : '正在只显示最新 ' + formatInt(PAGE_STEP) + ' 条 · 点击显示全部'; + parts.mode.setAttribute('aria-label', modeLabel); + parts.mode.setAttribute('title', modeLabel); } /** The topmost row that intersects the viewport, as {id, top}, or null. */ @@ -3824,13 +4254,13 @@

检视

renderInspector(); } - function updateFilterStatus(matched, total) { - dom.filterStatus.textContent = '命中 ' + formatInt(matched) + ' / 共 ' + formatInt(total) + ' 条'; - // The KPI cards are deliberately whole-session — the point of that panel is the - // session's shape, not the current query's. But a reader who just turned every - // tool result off and still reads "结果 1,409" directly underneath will conclude - // the filter failed, so the panel has to say which number it is showing. This is - // the one place every filter change passes through, kind chips and search alike. + /** + * The record counts live on the window bar, one line, where the buttons that change + * them are. This only carries the fact no count can: that the KPI cards underneath + * are whole-session numbers, not the current query's. Every filter change passes + * through here, kind buttons and search alike. + */ + function applyFilterScope() { if (dom.kpiScope) dom.kpiScope.hidden = !isFiltering(); } @@ -3848,9 +4278,9 @@

检视

function clearFilters() { state.kinds = KIND_ORDER.slice(); state.query = ''; - setWindowMode('latest'); + resetWindowMode(); if (dom.searchInput) dom.searchInput.value = ''; - syncChipInputs(); + syncKindButtons(); render(); } @@ -3912,7 +4342,7 @@

检视

var turn = entry.turn; var turnIndex = isCount(turn && turn.index) ? turn.index : null; dom.inspectorLocator.textContent = - '轮次 ' + (turnIndex === null ? DASH : turnIndex) + ' · #' + (isCount(record.index) ? record.index : DASH) + + '第 ' + (turnIndex === null ? DASH : turnIndex) + ' 轮 · #' + (isCount(record.index) ? record.index : DASH) + ' · ' + kindLabel(record); dom.inspectorMeta.textContent = record.id ? '记录 ' + record.id : ''; dom.tablist.hidden = false; @@ -4029,7 +4459,11 @@

检视

table.appendChild(caption); kvRow(table, '类型', record.kind === 'compaction' ? '压缩(上下文检查点)' : kindLabel(record)); kvRow(table, '角色', isNil(record.role) ? DASH : String(record.role)); - kvRow(table, '轮次', isCount(turn && turn.index) ? '轮次 ' + turn.index : DASH); + // A steer is a message the reader sent while this turn was still running. The file + // records nothing that says so — it is filed apart from the prompt that opened the + // turn by position — so the detail panel is where that inference gets stated. + if (record.steered) kvRow(table, '来源', '运行中追加(这一轮还没结束就发了过来)'); + kvRow(table, '轮次', isCount(turn && turn.index) ? '第 ' + turn.index + ' 轮' : DASH); kvRow(table, '记录序号', isCount(record.index) ? '#' + record.index : DASH); if (record.tokensBefore !== null && record.tokensBefore !== undefined) { kvRow(table, '压缩前上下文', formatInt(record.tokensBefore) + ' tokens'); @@ -4417,6 +4851,11 @@

检视

function renderFooter() { var session = state.payload ? state.payload.session : null; dom.footerTruncated.hidden = !(session && session.truncated); + // The server sends the cap it actually applied. Printing a number of its own meant the + // markup could drift from MAX_MESSAGE_BYTES and quietly under-report how much was cut. + if (session && isCount(session.maxMessageBytes)) { + dom.footerTruncated.textContent = '文件过大,仅显示前 ' + formatBytes(session.maxMessageBytes) + '。'; + } // Say plainly which conversation is on screen and how we know it. var binding = session ? session.binding : null; if (!session) { @@ -4462,13 +4901,37 @@

检视

announce(enabled ? '已开启跟随最新,正在跟踪最近的会话。' : '已关闭跟随最新,固定在所选会话。'); } - function syncChipInputs() { - var boxes = dom.toolbar.querySelectorAll('.chips input[type="checkbox"]'); - for (var i = 0; i < boxes.length; i += 1) { - boxes[i].checked = state.kinds.indexOf(boxes[i].value) >= 0; - boxes[i].parentNode.classList.toggle('is-on', boxes[i].checked); + function syncKindButtons() { + var buttons = dom.kindControls.querySelectorAll('.tbtn-action'); + for (var i = 0; i < buttons.length; i += 1) { + if (buttons[i].id !== 'kind-all') applyKindButton(buttons[i]); } - dom.chipAll.setAttribute('aria-pressed', state.kinds.length === KIND_ORDER.length ? 'true' : 'false'); + var all = state.kinds.length === KIND_ORDER.length; + var allBtn = document.getElementById('kind-all'); + allBtn.setAttribute('aria-pressed', all ? 'true' : 'false'); + allBtn.setAttribute('aria-label', all ? '当前显示全部类型 · 点击只看空白' : '当前显示部分类型 · 点击显示全部'); + allBtn.setAttribute('title', all ? '当前显示全部类型 · 点击只看空白' : '当前显示部分类型 · 点击显示全部'); + } + + /** + * One kind button. The pressed state IS whether that kind is in state.kinds, so + * there is no derived status that can drift away from what is on screen. Lit means + * the records are visible; dark means they are not, and the label says so. + */ + function applyKindButton(button) { + var on = state.kinds.indexOf(button.getAttribute('data-kind')) >= 0; + var noun = KIND_LABELS[button.getAttribute('data-kind')] || button.getAttribute('data-kind'); + var label = noun + (on ? '记录当前显示 · 点击隐藏' : '记录当前已隐藏 · 点击显示'); + button.setAttribute('aria-pressed', on ? 'true' : 'false'); + button.setAttribute('aria-label', label); + button.setAttribute('title', label); + button.querySelector('.tbtn-glyph').textContent = on ? '⊟' : '⊞'; + // The visible word comes from the same table as the accessible name. It used to be + // written only in the markup, so a rename left the button showing one word and + // announcing another — and nothing in the guards could see it, because the two lived + // in different places and both were individually correct. + var labelSpan = button.querySelector('.tbtn-label'); + if (labelSpan) labelSpan.textContent = noun; } function onKindToggle(value, checked) { @@ -4480,10 +4943,10 @@

检视

return KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b); }); state.kinds = next; - setWindowMode('latest'); - syncChipInputs(); + resetWindowMode(); + syncKindButtons(); renderLedger(); - updateFilterStatus(visibleEntries().length, state.flat.length); + applyFilterScope(); } function startPolling() { @@ -4535,7 +4998,7 @@

检视

// The pin changes what every count on the page refers to, so the selected record, // the page window and the validator all have to start over. state.selectedId = null; - setWindowMode('latest'); + resetWindowMode(); state.etag = ''; state.etagFor = ''; state.loading = true; @@ -4548,7 +5011,7 @@

检视

searchTimer = window.setTimeout(function () { searchTimer = null; state.query = dom.searchInput.value.trim(); - setWindowMode('latest'); + resetWindowMode(); renderLedger(); }, SEARCH_DEBOUNCE); }); @@ -4557,25 +5020,22 @@

检视

event.preventDefault(); }); - dom.chipAll.addEventListener('click', function () { - if (state.kinds.length === KIND_ORDER.length) { - state.kinds = []; - } else { - state.kinds = KIND_ORDER.slice(); - } - setWindowMode('latest'); - syncChipInputs(); - renderLedger(); - updateFilterStatus(visibleEntries().length, state.flat.length); - }); - - var boxes = dom.toolbar.querySelectorAll('.chips input[type="checkbox"]'); - for (var i = 0; i < boxes.length; i += 1) { - boxes[i].addEventListener('change', function (event) { - onKindToggle(event.target.value, event.target.checked); + var kindButtons = dom.kindControls.querySelectorAll('.tbtn-action'); + for (var j = 0; j < kindButtons.length; j += 1) { + if (kindButtons[j].id === 'kind-all') continue; + kindButtons[j].addEventListener('click', function () { + onKindToggle(this.getAttribute('data-kind'), this.getAttribute('aria-pressed') !== 'true'); }); } + document.getElementById('kind-all').addEventListener('click', function () { + state.kinds = state.kinds.length === KIND_ORDER.length ? [] : KIND_ORDER.slice(); + resetWindowMode(); + syncKindButtons(); + renderLedger(); + applyFilterScope(); + }); + dom.overviewClear.addEventListener('click', clearOverviewSelection); dom.headerToggle.addEventListener('click', function () { @@ -4637,7 +5097,7 @@

检视

applyTheme(); cacheDom(); wire(); - syncChipInputs(); + syncKindButtons(); renderSectionToggles(); render(); fetchSessions(); diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index def6a17..24c8bba 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -203,6 +203,23 @@ export function activeCatalogBytes(catalog) { return found ? total : null; } +/** + * Whether our own read cap, and nothing else, cut a file short. + * + * The catalog-recorded sizes are a snapshot, and the active `messages.jsonl` keeps being appended + * to after the catalog was written — so "we read fewer bytes than the file now has" is not evidence + * that the file is too large. Reporting it as truncation is how a merely-active session ended up + * claiming the first 64 MB when it was nowhere near it. + * + * @param {number} size the file's size at read time + * @param {number} limit the bytes we were willing to read + * @returns {boolean} + */ +export function truncatedByCap(size, limit) { + if (!Number.isFinite(size) || !Number.isFinite(limit)) return false; + return size > limit; +} + /** * @param {string} filePath * @param {number} limit @@ -597,6 +614,10 @@ export async function resolveCurrentConversation(home) { }; } catch { return null; + } finally { + // Every call opened its own handle and none of them closed it. Polling every 2.5s meant + // 20–30 live handles waiting on the GC, and dispose() left them open too. + closeQuietly(db); } } @@ -610,8 +631,9 @@ export async function resolveCurrentConversation(home) { export async function readSessionTitles(home, sessionIds) { const result = new Map(); const db = await openRuntimeStateDb(home); - if (!db || sessionIds.length === 0) return result; + if (!db) return result; try { + if (sessionIds.length === 0) return result; const stmt = db.prepare('select session_id, title, session_kind from local_runtime_sessions where session_id = ?'); for (const id of sessionIds) { const row = stmt.get(id); @@ -623,10 +645,27 @@ export async function readSessionTitles(home, sessionIds) { } } catch { // A title is a nicety; never let it fail the list. + } finally { + closeQuietly(db); } return result; } +/** + * Closing a read-only handle cannot fail in a way the caller can act on — the work it was + * opened for is already done or already abandoned. Swallowing keeps every call site from + * having to wrap its own error handling around cleanup. + * @param {{ close?: () => void } | null} db + */ +function closeQuietly(db) { + if (!db || typeof db.close !== 'function') return; + try { + db.close(); + } catch { + // Nothing to do: the handle is being released either way. + } +} + function emptyTokens() { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; } @@ -801,7 +840,10 @@ export function buildTrajectoryPayload(input) { compactions: 0, turns: 0, turnsWithPrompt: 0, + continuationTurns: 0, qaTurns: 0, + openTurns: 0, + steers: 0, compactionTurns: 0, turnsWithoutPrompt: 0, errors: 0, @@ -975,7 +1017,14 @@ export function buildTrajectoryPayload(input) { if (kind === 'text') { if (textField.text === null) continue; push({ - kind: 'assistant', + // The model says why it stopped, and that sentence is the whole test. `stop` + // means it finished, so this text is the answer the turn delivers. `toolUse` + // means it is about to call a tool, so this text is the commentary it wrote on + // the way there. A third value, `aborted`, marks a turn cut off part-way; its + // text never became an answer, so it stays with the commentary rather than + // being passed off as one. Filing all of them under one label buried the + // answers under several times their own volume. + kind: source.stopReason === 'stop' ? 'reply' : 'narration', title: 'assistant', summary: singleLine(textField.text, MAX_SUMMARY_CHARS), text: textField.text, @@ -1016,8 +1065,13 @@ export function buildTrajectoryPayload(input) { if (toolCallId) toolCallsById.set(toolCallId, { record, toolCallId }); } } - if (turn.records.length === 0) { - push({ kind: 'assistant', title: 'assistant', summary: '', text: null, tokens: carrierUsage() }); + // The test is this message's own segments, not the turn's record count. `turn.records` + // already holds everything earlier in the turn, so an assistant message carrying only + // `content: []` — a stopReason 'error', or a turn cut off before it said anything — added + // no row once any earlier message had produced one, while its usage still landed on the + // turn's Token total. The page then showed a number it could not attribute to any line. + if (segmentOrder.length === 0) { + push({ kind: source.stopReason === 'stop' ? 'reply' : 'narration', title: 'assistant', summary: '', text: null, tokens: carrierUsage() }); } continue; } @@ -1059,15 +1113,27 @@ export function buildTrajectoryPayload(input) { } if (split.prompt !== null) { - stats.userMessages += 1; + // The first prompt of a turn is what opened it. Anything later arrived while the + // turn was still running — a steer, redirecting work already under way. + // + // Nothing in the session file marks this. Compared field by field, a steer is + // byte-identical to a typed prompt in role, stopReason, api, model, provider, tokens, + // producedBy, turnId and everything else; only id, timestamp and index differ. The + // signal available is position: measured across every turn that has one, its prompt + // sits at record 1 or 2 and never further in, so a user record past that point can + // only have been written mid-turn. That is an inference from ordering, not a fact + // the file asserts, and it is recorded as `steered` so the detail panel can say so. + const steered = turn.records.some((r) => r.kind === 'user'); + if (steered) stats.steers += 1; else stats.userMessages += 1; if (label === null) label = singleLine(redact(split.prompt), MAX_LABEL_CHARS); const promptField = clipField(redact(split.prompt), MAX_FIELD_CHARS); push({ - kind: 'user', + kind: steered ? 'steer' : 'user', title: 'user', summary: singleLine(promptField.text ?? '', MAX_SUMMARY_CHARS), text: promptField.text, textTruncated: promptField.truncated, + steered, }); } @@ -1145,49 +1211,78 @@ export function buildTrajectoryPayload(input) { } stats.turns = turns.length; - // A raw turn total is the number most likely to be misread. Every turn is exactly one of - // four things, and saying which is the difference between "why is this not what I counted" - // and having to go dig through the file. + // A raw turn total is the number most likely to be misread, so every turn is filed and the + // count is broken down. // - // 对话 a turn somebody typed into - // 问答 the answering half of a question the previous turn asked — the reply arrives - // through the question itself, so no user message is ever written for it - // 压缩 a turn that opened a context generation; a checkpoint, not conversation - // 其他 a turn with no prompt and nothing pointing at what caused it + // 对话 a turn somebody typed into — has a first-of-turn prompt + // 续作 no prompt, but it closed with a reply: the agent working after the reader + // answered a question, which arrives through the tool channel rather than + // as a message + // 压缩 a turn that opened a context generation; a checkpoint, not conversation + // 其他 neither a prompt nor a reply // - // The 问答 case is a structural inference, not a guess at the content: walking forward from - // the start of the ledger, a turn that calls a tool which waits on the reader arms the flag, - // and the first prompt-less turn after that is the answer. Any turn carrying a real prompt - // disarms it again, so an ordinary typed reply is never mistaken for one. + // 问答 is not a turn class, which is what it was mistaken for. It is a prompted turn that + // did not close: it ends on the “is waiting for the local user” result of a tool that waits on + // the reader. Labelling the *next* turn instead — as an earlier version did — put the tag on + // a turn while the reader still had not answered, and on turns holding hundreds of records + // that were plainly work rather than a Q&A. + // + // The other thing worth saying: a turn either ends with exactly one reply (stopReason: stop) + // or with none. Measured over 63 prompted turns, 54 closed, 5 stopped on ask_user, 4 were cut + // off. So whether a turn finished is a fact about the data, and it is the fact the counts + // are built from. let turnsWithPrompt = 0; + let continuationTurns = 0; let qaTurns = 0; + let openTurns = 0; let compactionTurns = 0; - let awaitingReply = false; for (const turn of turns) { let hasPrompt = false; let isCompaction = false; let askedReader = false; + let closed = false; for (const record of turn.records) { - // Injected blocks were already demoted to system records, so a user record here is a - // prompt somebody actually typed. + // Injected blocks were demoted to system records, and a later prompt is filed as a + // steer, so a `user` record here is the prompt that opened the turn. if (record.kind === 'user') hasPrompt = true; if (record.kind === 'compaction') isCompaction = true; + if (record.kind === 'reply') closed = true; if (record.kind === 'toolCall' && typeof record.toolName === 'string' && USER_INPUT_TOOLS.has(record.toolName)) askedReader = true; } - if (isCompaction) compactionTurns += 1; - else if (hasPrompt) { turnsWithPrompt += 1; awaitingReply = false; } - else if (awaitingReply) { qaTurns += 1; awaitingReply = false; } - else stats.turnsWithoutPrompt += 1; - if (askedReader) awaitingReply = true; + turn.askedReader = askedReader; + turn.closed = closed; + if (isCompaction) { turn.turnClass = 'compaction'; compactionTurns += 1; continue; } + if (hasPrompt) { + turn.turnClass = 'prompt'; + turnsWithPrompt += 1; + if (!closed) openTurns += 1; + if (askedReader && !closed) qaTurns += 1; + } else if (closed) { + turn.turnClass = 'continuation'; + continuationTurns += 1; + } else { + turn.turnClass = 'none'; + stats.turnsWithoutPrompt += 1; + } } stats.turnsWithPrompt = turnsWithPrompt; + stats.continuationTurns = continuationTurns; stats.qaTurns = qaTurns; + stats.openTurns = openTurns; stats.compactionTurns = compactionTurns; if (startedAt !== null && lastTimestamp !== null) stats.durationMs = lastTimestamp - startedAt; const resolvedLabel = label ?? `会话 ${String(session.id).slice(4, 12)}`; - const generations = Array.isArray(input.generations) ? input.generations : []; + const orphans = Array.isArray(input.orphans) ? input.orphans : []; + // The catalog can list a generation the lineage walk rejected: a snapshot left behind by a fork + // is on disk but is not part of this conversation. Summing those into `messagesAll` inflated the + // session-wide count, and listing them in `generations` offered the reader a pick that resolved + // to nothing. Both are fixed by reporting the chain rather than the catalog — the rejected ones + // are still disclosed through `orphans`. + const orphanFiles = new Set(orphans.map((orphan) => orphan.fileName)); + const generations = (Array.isArray(input.generations) ? input.generations : []) + .filter((entry) => !orphanFiles.has(entry.fileName)); // `messages` counts what is on screen. When the whole lineage is stitched that already // spans every generation; when the reader filtered to one, `messagesAll` keeps the // session-wide number from silently shrinking to a single slice. @@ -1205,9 +1300,12 @@ export function buildTrajectoryPayload(input) { lastActiveAt: session.createdAtMs ?? startedAt, sizeBytes, truncated, + // The page prints this number when it reports truncation, so it is sent rather than + // restated in the markup where the two could drift apart again. + maxMessageBytes: MAX_MESSAGE_BYTES, generation: input.generation ?? null, generations, - orphans: Array.isArray(input.orphans) ? input.orphans : [], + orphans, }, stats: { ...stats, messagesAll }, turns: turns.map((turn) => ({ @@ -1219,6 +1317,13 @@ export function buildTrajectoryPayload(input) { toolCalls: turn.toolCalls, errors: turn.errors, tokens: turn.tokens, + // prompt | continuation | compaction | none. + turnClass: turn.turnClass ?? 'none', + // Whether this turn asked the reader something and therefore has not closed yet, and + // whether it ended with the answer it owes. Together they explain every gap between the + // turn count and the reply count. + askedReader: turn.askedReader === true, + closed: turn.closed === true, records: turn.records, })), }; @@ -1530,17 +1635,15 @@ export async function start(context) { } sendJson(response, 200, { nodeVersion: process.version, - pid: process.pid, - ppid: process.ppid, - execArgv: process.execArgv, - argv, - envNames, - envSessionLike, - argvSessionLike: argv.filter((entry) => SESSION_ID.test(entry)), + // Everything below is a basename, a count, or a flag. `argv[0]` is the absolute path of + // the node executable — on Windows it carries the user profile directory — and + // execArgv/pid/ppid are process internals this route has no reason to publish. The page + // reads none of them; the route exists to answer "is the runtime binding alive", and the + // fields that answer that are `sqlite` and `sessionRootExists`. + sqlite, cwdBasename: basename(process.cwd()) || null, dataDirBasename: basename(context.dataDir) || null, sessionRootExists: existsSync(location.sessionsRoot), - sqlite, }); } catch (error) { diagError = String(/** @type {any} */ (error)?.stack ?? error).slice(0, 600); @@ -1700,10 +1803,7 @@ export async function start(context) { const filePath = resolveArtifactPath(entry.dir, artifact); if (filePath === null) return null; if (probed.has(artifact.fileName)) return probed.get(artifact.fileName); - const limit = artifact.active - ? Math.min(entry.sizeBytes, identity.catalogBytes ?? MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES) - : Math.min(artifact.byteLength ?? MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES); - const head = await readHead(filePath, Math.min(limit, GENERATION_PROBE_BYTES)); + const head = await readHead(filePath, GENERATION_PROBE_BYTES); const result = { fileName: artifact.fileName, marker: readGenerationMarker(head.text) }; probed.set(artifact.fileName, result); return result; @@ -1739,10 +1839,13 @@ export async function start(context) { sendError(response, 500, 'trajectory_unreadable', '会话数据暂时无法读取'); return; } - const isActive = link.fileName === activeFileName; - const limit = isActive - ? Math.min(entry.sizeBytes, identity.catalogBytes ?? MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES) - : Math.min(artifact === null ? MAX_MESSAGE_BYTES : (artifact.byteLength ?? MAX_MESSAGE_BYTES), MAX_MESSAGE_BYTES); + // Bound the read by our own cap only. The catalog's recorded sizes are a snapshot and the + // active messages.jsonl keeps being appended to after the catalog was written, so using + // them as a read limit both dropped records that were really on disk and — because the read + // then stopped short of the file — made `head.read < head.size` report "file too large" for + // a file that was merely newer than its catalog entry. `readHead` already clamps to the real + // stat size, so MAX_MESSAGE_BYTES bounds memory on its own. + const limit = MAX_MESSAGE_BYTES; let head; try { head = await readHead(filePath, limit); @@ -1754,7 +1857,8 @@ export async function start(context) { if (head.text === '') { context.logger.warn('miniapp.trajectory.read_error', { reason: 'empty_messages' }); } - if (head.read < head.size) truncated = true; + // Truncated means our cap cut the read, nothing else. + if (truncatedByCap(head.size, limit)) truncated = true; readBytes += head.read; const parsed = parseMessagesJsonl(head.text); skippedTotal += parsed.skipped; From 86b1895d6741f9277e13fefa79657482c6c8a1e0 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 02:07:36 +0800 Subject: [PATCH 11/19] Drop the argv and env scan /api/runtime no longer reports --- .../mmc-trajectory/miniapp/node/server.mjs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index 24c8bba..b8a1d5c 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -1594,17 +1594,6 @@ export async function start(context) { const handleRuntime = async (response) => { let diagError = null; try { - const SESSION_ID = /mvs_[0-9a-f]{32}/; - /** @type {Record} */ - const envSessionLike = {}; - const envNames = Object.keys(process.env).sort(); - for (const key of envNames) { - const value = process.env[key]; - if (typeof value !== 'string') continue; - const found = SESSION_ID.exec(value); - if (found) envSessionLike[key] = found[0]; - } - const argv = process.argv.map((entry) => (typeof entry === 'string' ? entry.slice(0, 160) : String(entry))); let sqlite = { available: false, error: null, dbFile: null, exists: false, opened: false, queryError: null, resolvedSessionId: null, conversationCount: null }; /** @type {any} */ let mod = null; @@ -1635,11 +1624,11 @@ export async function start(context) { } sendJson(response, 200, { nodeVersion: process.version, - // Everything below is a basename, a count, or a flag. `argv[0]` is the absolute path of - // the node executable — on Windows it carries the user profile directory — and - // execArgv/pid/ppid are process internals this route has no reason to publish. The page - // reads none of them; the route exists to answer "is the runtime binding alive", and the - // fields that answer that are `sqlite` and `sessionRootExists`. + // Everything below is a basename, a count, or a flag. This route deliberately publishes no + // path and no process internals: `process.argv[0]` is the absolute path of the node + // executable, which on Windows carries the user profile directory, and pid/ppid/execArgv + // are internals the page has no use for. The page reads none of this; the route exists to + // answer "is the runtime binding alive", and `sqlite` plus `sessionRootExists` answer that. sqlite, cwdBasename: basename(process.cwd()) || null, dataDirBasename: basename(context.dataDir) || null, From c6634f2b453d4000d5a3593094a7fa3d7c480c95 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 02:38:44 +0800 Subject: [PATCH 12/19] Put a compaction checkpoint back inside the turn it interrupted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A checkpoint was filed under ":compaction" while every row after it carried the bare id, so keying turns on the whole string opened a turn for the checkpoint alone. Turns join the list the moment they are first seen, and the interrupted turn kept collecting records after the checkpoint had been seen — so the checkpoint was emitted after the entire turn it belonged to. On a 10,500-record session that showed up as 12 turns of exactly one row each, a numbering that ran backwards (#1037 then #970), and timestamps stepping back up to 20 minutes. Keying the checkpoint on the bare id puts it back where it happened. Measured on the live session: turns with a hole in their index range 12 -> 0, timestamp inversions caused by a checkpoint 12 -> 0, and the ones that remain (17, all a steer after the tool result it interrupted) are left alone on purpose — a steer carries the time the reader typed, and sorting it back into place would put it before the calls it was answering. A turn that *contains* a checkpoint is still judged on what it did. "Contains one" used to be the same as "is one" back when a checkpoint was a turn of its own; keeping that would relabel every compacted turn and drop all of them out of the prompted count. Only a turn holding nothing but its checkpoint is one. The checkpoint is drawn as a full-width band rather than as another row. It is still one filterable record — the 压缩 chip counts and hides it like any other — but the rows above and below it are no longer the same conversation as far as the model is concerned, and one line says so instead of leaving a gap to infer from. That band's number now comes from the record count, because the turn count is zero for every session that was ever compacted. Both counters were also being kept honest about what they count: a record's `turnId` now names the turn it is actually in, since the file's suffixed id names no turn at all. --- .../mmc-trajectory/miniapp/client/index.html | 120 +++++++++++++++++- .../mmc-trajectory/miniapp/node/server.mjs | 36 +++++- 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 27417a1..dd1e680 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -1277,6 +1277,69 @@ border-color: currentColor; } + /* ---------------- compaction band ---------------- + A context checkpoint is not another piece of work, so it is not drawn as another row. + It gets a full-width band: the reader sees where the context switched without having to + read past it. The rules above it and below it are the point, so the band separates them + rather than joining the list. + The text carries the meaning on its own; the danger tone is only reinforcement. */ + .row-list > li.compaction-cell { + list-style: none; + } + + .compaction-band { + display: grid; + grid-template-columns: 42px auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; + margin: 6px 0; + padding: 7px 16px; + border: 0; + border-top: 1px solid var(--mcode-border); + border-bottom: 1px solid var(--mcode-border); + background: var(--mcode-surface-muted); + color: var(--mcode-text-subtle); + font: inherit; + text-align: left; + cursor: pointer; + } + + .compaction-band:hover { + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); + } + + .compaction-band:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: -2px; + } + + .compaction-band[aria-current='true'] { + background: var(--mcode-selection); + color: var(--mcode-text); + } + + .compaction-band .row-index { + color: var(--mcode-text-subtle); + } + + .compaction-band .compaction-name { + color: var(--mcode-danger); + font-weight: 600; + white-space: nowrap; + } + + .compaction-band .compaction-what { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .compaction-band .compaction-when { + white-space: nowrap; + } + .pill-error { color: var(--mcode-danger); border-color: currentColor; @@ -2742,8 +2805,11 @@

检视

if (isCount(stats.steers) && stats.steers > 0) { parts.push({ label: '追加', value: formatInt(stats.steers), kind: 'steer' }); } - if (isCount(stats.compactionTurns) && stats.compactionTurns > 0) { - parts.push({ label: '压缩', value: formatInt(stats.compactionTurns), kind: 'compaction' }); + if (isCount(stats.compactions) && stats.compactions > 0) { + // Records, not turns. A checkpoint is filed inside the turn it interrupted, so counting + // turns would report 0 for every session that was ever compacted — which is the one + // thing the reader most wants to know happened. + parts.push({ label: '压缩', value: formatInt(stats.compactions), kind: 'compaction' }); } if (isCount(stats.continuationTurns) && stats.continuationTurns > 0) { parts.push({ label: '续作轮', value: formatInt(stats.continuationTurns), kind: null }); @@ -3731,6 +3797,49 @@

检视

return row; } + /** + * A context checkpoint, drawn as a band between the rows on either side of it. + * + * It stays one filterable record — the 压缩 chip counts and hides it like any other — but + * it is not laid out like one, because it is not one: the runtime summarised everything + * before it, so the rows above and the rows below are no longer the same conversation as + * far as the model is concerned. Saying so on one line beats making the reader infer it + * from a gap. + * + * @param {{ record: Record }} entry + * @returns {HTMLButtonElement} + */ + function buildCompactionBand(entry) { + var record = entry.record; + var band = el('button', 'compaction-band'); + band.type = 'button'; + band.setAttribute('data-row-id', record.id || String(record.index)); + if (record.id === state.selectedId) band.setAttribute('aria-current', 'true'); + else band.removeAttribute('aria-current'); + + band.appendChild(el('span', 'row-index', '#' + (isCount(record.index) ? record.index : DASH))); + band.appendChild(el('span', 'compaction-name', '上下文压缩')); + + // Say which generation the checkpoint opened, and what came before it. Both are on the + // row; a band that only said 「压缩」 would leave the reader guessing what changed. + var opened = isCount(record.generation) + ? '开启第 ' + formatInt(record.generation) + ' 代' + : '开启新的一代'; + var summary = truncateText(safeText(record.summary || record.text || ''), 200); + band.appendChild(el('span', 'compaction-what', summary ? summary : '之前的上下文已被摘要')); + + var when = isNil(record.relativeMs) + ? (isCount(record.timestamp) ? formatClock(record.timestamp) : DASH) + : formatOffset(record.relativeMs); + band.appendChild(el('span', 'compaction-when', when)); + band.title = '上下文压缩 · ' + opened + (summary ? ' · ' + truncateText(safeText(summary), 400) : ''); + + band.addEventListener('click', function () { + selectRecord(record.id); + }); + return band; + } + function kindTone(kind) { if (kind === 'user') return 'user'; if (kind === 'steer') return 'steer'; @@ -4014,7 +4123,12 @@

检视

var rails = railStates(group.entries); for (var j = 0; j < group.entries.length; j += 1) { var item = el('li'); - item.appendChild(buildRow(group.entries[j], rails[j])); + if (group.entries[j].record.kind === 'compaction') { + item.className = 'compaction-cell'; + item.appendChild(buildCompactionBand(group.entries[j])); + } else { + item.appendChild(buildRow(group.entries[j], rails[j])); + } list.appendChild(item); } section.appendChild(list); diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index b8a1d5c..99bd29e 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -20,6 +20,11 @@ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024; * no user message because the reader's answer never became one. */ const USER_INPUT_TOOLS = new Set(['ask_user', 'request_feature_enable']); +/** + * Suffix the runtime appends to a turn id when it writes that turn's compaction checkpoint. The + * checkpoint names the turn it interrupts, so stripping this is what puts the two back together. + */ +const COMPACTION_TURN_SUFFIX = ':compaction'; // Enough of a file to read its opening line. A generation marker lives on the first row, so // deciding whether a snapshot belongs to the lineage never needs more than this. const GENERATION_PROBE_BYTES = 64 * 1024; @@ -884,7 +889,20 @@ export function buildTrajectoryPayload(input) { const role = typeof source.role === 'string' ? source.role : 'unknown'; const timestamp = finiteOrNull(source.timestamp); const turnId = typeof row.turn_id === 'string' ? row.turn_id : null; - const turn = ensureTurn(turnId); + // A compactionSummary is written under ":compaction" — the id of the turn it + // interrupted, plus a suffix — while every row after it carries the bare id. Keying on the + // whole string therefore opened a turn for the checkpoint alone, and because a turn joins the + // list the moment it is first seen, that turn was emitted after the entire turn it belongs to: + // the checkpoint rendered past records that came later, and the numbering ran backwards + // (#1037 then #970). Keying the checkpoint on the bare id puts it back where it happened — + // inside the turn it interrupted, which is also the only place a context checkpoint is. + const turnKey = role === 'compactionSummary' + && turnId !== null + && turnId.length > COMPACTION_TURN_SUFFIX.length + && turnId.endsWith(COMPACTION_TURN_SUFFIX) + ? turnId.slice(0, -COMPACTION_TURN_SUFFIX.length) + : turnId; + const turn = ensureTurn(turnKey); if (timestamp !== null) { if (startedAt === null || timestamp < startedAt) startedAt = timestamp; if (lastTimestamp === null || timestamp > lastTimestamp) lastTimestamp = timestamp; @@ -914,7 +932,10 @@ export function buildTrajectoryPayload(input) { const messageId = typeof row.message_id === 'string' ? row.message_id : null; const base = { id: messageId, - turnId, + // The turn this record belongs to, which for a checkpoint is the turn it interrupted — not + // the suffixed id the file wrote. A record whose `turnId` names no turn is a contradiction + // the page cannot resolve. + turnId: turnKey, role, // Stamped when a lineage is stitched, so the page can group or filter by generation // without having to re-derive it from timestamps. @@ -1252,7 +1273,11 @@ export function buildTrajectoryPayload(input) { } turn.askedReader = askedReader; turn.closed = closed; - if (isCompaction) { turn.turnClass = 'compaction'; compactionTurns += 1; continue; } + // A turn that *contains* a checkpoint is still judged on what it did. A checkpoint used to be + // a turn of its own, so "contains one" was the same as "is one"; now that the checkpoint + // rejoins the turn it interrupted, that would relabel every turn the runtime ever compacted + // as a checkpoint and drop all of them out of the prompted count. Only a turn that holds + // nothing but its checkpoint is one. if (hasPrompt) { turn.turnClass = 'prompt'; turnsWithPrompt += 1; @@ -1261,6 +1286,11 @@ export function buildTrajectoryPayload(input) { } else if (closed) { turn.turnClass = 'continuation'; continuationTurns += 1; + } else if (isCompaction && turn.records.length === 1) { + // Only reachable when a checkpoint opens a turn the file never names — no work either side + // of it to attach to. + turn.turnClass = 'compaction'; + compactionTurns += 1; } else { turn.turnClass = 'none'; stats.turnsWithoutPrompt += 1; From 205c43c37d0b71cb0a303ccf960af888baf4b59c Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 10:04:16 +0800 Subject: [PATCH 13/19] Give the record-index column room for the numbers it has to show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "#1085 appears more than once". No index was duplicated: the payload holds 11030 records and 11030 distinct indexes, and the ledger builds one row per entry. The number was being cut off on screen. The first grid track was 42px, which holds "#" plus four digits — the whole of every session until one passed 10,000 records. Past that the fifth digit left the track, and a grid item is `overflow: visible` by default, so it spilled into the 76px kind column and was painted over by the opaque pill. #10850..#10859 all read #1085; ten consecutive rows showed the reader one number. Nothing errored, and unlike a wrong number this one cannot be checked — it looks exactly like a real index. Three grids render a record index and all three were 42px; the max-width: 767px override narrowed it to 28px, too small even for four digits, which is what made the phone layout the worst case. They now share one `--traj-index-col: 72px`. 72px is derived, not chosen: the old track held five characters and would not hold six, so one character is at most 42 / 5 = 8.4px, and a label is "#" plus at most seven digits — 8 x 8.4 = 67.2px. A session would have to reach 10,000,000 records before this bites again. .row-index also gets `text-overflow: ellipsis` so that if a column is ever too narrow again the row says it could not show the number in full, instead of quietly displaying a different valid one. Verified in the browser at 72px: .row-index measures width 72 on both the plain row (#11023, x 31-103) and the compaction band (#11034, x 33-105), with the kind pill starting at x 109 and x 113 respectively, and consecutive indexes #11049..#11069 render complete and distinct. --- .../mmc-trajectory/miniapp/client/index.html | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index dd1e680..cfc9ada 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -1093,6 +1093,28 @@ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + /* Width of the 记录序号 column, shared by `.row`, `.compaction-band` and + `.skeleton-row` so the three ledgers cannot drift apart. + + This is the one column whose width is set by the *data*, not by the text it + happens to hold today. 42px fits `#` plus four digits, which was the whole of + every session until one passed 10,000 records. Past that the fifth digit left + the track, and a grid item has `overflow: visible` by default: the digit did not + disappear, it spilled into the 76px kind column and was painted over by the + opaque pill. `#10950` through `#10959` all read as `#1095`. Nothing errored and + no index was ever duplicated — ten consecutive rows simply showed the reader one + number, which is worse than a wrong number, because a wrong number is checkable. + + 72px holds `#` plus seven digits even at the widest advance the old track allowed + — 42px fit five characters, so one is at most 8.4px, and 8 × 8.4 = 67.2 — so a + session would have to reach 10,000,000 records before this bites again. The width + belongs here rather than in the media query for the same reason: the narrow rule + used to narrow this column too, which is what made 28px — too small even for four + digits — the phone behaviour. */ + :root { + --traj-index-col: 72px; + } + .row-list { list-style: none; margin: 0; @@ -1105,7 +1127,7 @@ .row { display: grid; - grid-template-columns: 42px 76px minmax(0, 1fr) 68px 76px; + grid-template-columns: var(--traj-index-col) 76px minmax(0, 1fr) 68px 76px; align-items: center; gap: 8px; width: 100%; @@ -1187,6 +1209,12 @@ font-size: 12px; color: var(--mcode-text-muted); font-variant-numeric: tabular-nums; + /* Containment, so that a column ever too narrow again fails visibly instead of + spilling onto the kind pill. A spill reads as a different, valid index; an + ellipsis reads as a number this row could not show in full. */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .row-summary { @@ -1289,7 +1317,7 @@ .compaction-band { display: grid; - grid-template-columns: 42px auto minmax(0, 1fr) auto; + grid-template-columns: var(--traj-index-col) auto minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; @@ -1436,7 +1464,7 @@ .skeleton-row { display: grid; - grid-template-columns: 42px 76px minmax(0, 1fr) 68px 76px; + grid-template-columns: var(--traj-index-col) 76px minmax(0, 1fr) 68px 76px; gap: 8px; align-items: center; padding: 4px 16px; @@ -1694,10 +1722,13 @@ max-width: none; } - /* All five columns stay so the 失败 text never disappears on narrow screens. */ + /* All five columns stay so the 失败 text never disappears on narrow screens. + The index column is deliberately left at its own width: it is the one track + sized by data rather than by the text it holds, and the 28px this rule used + to give it could not even fit four digits. */ .row, .skeleton-row { - grid-template-columns: 28px 64px minmax(0, 1fr) 54px 66px; + grid-template-columns: var(--traj-index-col) 64px minmax(0, 1fr) 54px 66px; gap: 6px; padding: 4px 12px; } From 9621490478a66463aace91c38fa301fb00dbe3a0 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 11:10:57 +0800 Subject: [PATCH 14/19] feat(mmc-trajectory): filter records by a session-relative time range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 时间区间 row under the search box — 起点 / 终点 boxes plus 更新到最新 and 清空区间. Empty by default, and the table, the window count and the KPI strip all read the same one filter. - Input is a session-relative offset (`1:30`, `1:30:00`, a bare number reads as minutes) because the ledger already prints `+893 分 21 秒`; wall-clock lines up with nothing else on the page. The hint always echoes the parsed value back, so what the filter is actually using is never a guess. - KPI is recomputed client-side from the same `state.flat` records the table reads, and the probe proves it equals `payload.stats` field for field across all 15 fields on the real 11,812-record payload. Header and table cannot drift onto two definitions. - A record matches on its own offset; a turn matches when any record inside it matches, and is then counted whole. `messagesAll` stays 全场 so the comparison label still means something. - The range is a snapshot, not a subscription. `rangeCutoff` pins the highest record index when the range is set, so new records cannot append themselves, and 更新到最新 is what moves it. Measured: 共 11,679 → 11,685 while 已显示 654 held still. - Setting a range calls `resetWindowMode()` like every other filter. Forcing 「全部」 was measured and dropped: a range starting at 20:00 covers the whole session, and 全部 then rendered ~11,000 rows and took the document to 414,000px, where one query took 19s. 不自动追加 does not depend on it — the cutoff holds the range on its own. - A box the page cannot read is never written into state. `NaN` fails the `isCount` test behind `hasTimeRange`, so storing it would switch the whole range off mid-keystroke and quietly hand back the entire session. The hint reads the boxes rather than state, which is what makes the 「格式无法识别」 message reachable at all. - 清空区间 hides itself while no range is set, and 清空 clears the range too. --- .../mmc-trajectory/miniapp/client/index.html | 547 +++++++++++++++++- 1 file changed, 545 insertions(+), 2 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index cfc9ada..b3cacec 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -412,6 +412,51 @@ width: 100%; } + /* The time range is measured from the start of the session, because that is the only + clock the ledger already prints. The `+893 分 21 秒` column, the overview axis and the + hint under these inputs are all offsets from the same zero, so a reader can copy the + number they are looking at straight into 起点. A wall-clock picker would have been + the more obvious control and would have matched nothing else on the page. */ + .range-part { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + } + + .range-part-label { + font-size: 12px; + color: var(--mcode-text-muted); + white-space: nowrap; + } + + .range-input { + width: 88px; + font-variant-numeric: tabular-nums; + } + + .range-sep { + font-size: 12px; + color: var(--mcode-text-subtle); + } + + .range-hint { + margin: 0; + font-size: 12px; + color: var(--mcode-text-muted); + } + + .range-hint[hidden] { + display: none; + } + + /* The reader has to be able to tell three apart: no range set, a range in force, and a + half-typed value the page could not read. Colour alone cannot carry that, so the + invalid state is also a word. */ + .range-hint.is-invalid { + color: var(--mcode-danger); + } + .chipbar { display: flex; flex-wrap: wrap; @@ -1831,6 +1876,24 @@

会话轨迹

+
+
+ 时间区间 + + + + + +
+
+

留空 = 不限。填写会话开始后的时间,格式 1:30 或 1:30:00。

+
@@ -2116,6 +2179,99 @@

检视

return value < 10 ? '0' + value : String(value); } + /* ---------------- time range ---------------- + Offsets in milliseconds since the session started. This is the one clock the ledger + already prints on every row, so the number a reader is looking at can be typed + straight in; a wall-clock field would have matched nothing else on the page. + + Three outcomes, kept apart on purpose: + null → empty, that end is open (or the whole filter is off) + NaN → the text is not a time; the filter keeps its last readable value + >= 0 → the offset in ms + + `1:30` and `1:30:00` are both read as minutes and minutes:seconds, matching the + `893 分 21 秒` the row shows. Bare digits are minutes, because "the first ten + minutes" is what a reader types, and the hint always prints the resolved value back + so a misreading is visible rather than silent. */ + + /** + * @param {string} text + * @returns {number|null} ms, `null` when empty, `NaN` when unreadable + */ + function parseRangeOffset(text) { + var raw = String(text === null || text === undefined ? '' : text).trim(); + if (raw === '') return null; + // A bare number is minutes. Anything else must be colon-separated, so `12:34` is + // never read as twelve hundred thirty-four seconds by accident. + var parts = raw.indexOf(':') >= 0 ? raw.split(':') : [raw]; + if (parts.length > 3) return NaN; + var numbers = []; + for (var i = 0; i < parts.length; i += 1) { + var piece = parts[i].trim(); + // `isCount` would accept `1e3` and `12.5`; neither is a time. + if (!/^\d{1,4}$/.test(piece)) return NaN; + var value = Number(piece); + // Every part but the first is a smaller unit, so 90:00 is not a valid 90 minutes. + if (i > 0 && value > 59) return NaN; + numbers.push(value); + } + // The unit of each part is fixed by its position, so the total is built from the + // seconds rather than by folding the parts and scaling once: folding + // `1:30:00` gives 5400 — hours, not minutes — and scaling that as minutes is how a + // three-part offset came out sixty times too large. + var seconds = numbers.length === 1 + ? numbers[0] * 60 + : numbers.length === 2 + ? numbers[0] * 60 + numbers[1] + : numbers[0] * 3600 + numbers[1] * 60 + numbers[2]; + return seconds * 1000; + } + + /** + * @param {number} ms + * @returns {string} `1:30:00`, or `1:30` while it fits in minutes + */ + function formatRangeOffset(ms) { + if (!isCount(ms)) return DASH; + var total = Math.floor(Math.abs(ms) / 1000); + var hours = Math.floor(total / 3600); + var minutes = Math.floor((total % 3600) / 60); + var seconds = total % 60; + if (hours > 0) return hours + ':' + pad2(minutes) + ':' + pad2(seconds); + return minutes + ':' + pad2(seconds); + } + + /** + * Whether one record falls inside `[from, to]`, in milliseconds since session start. + * Both bounds are inclusive; `null` on a side leaves it open, and both null keeps + * everything. + * + * A record with no timestamp has no place on this clock, so a range leaves it out. That + * matches the overview brush, which drops a record it cannot position rather than + * guessing — and a range that quietly kept undated records would report a count it + * could not account for. + * + * @param {any} record + * @param {number|null} from + * @param {number|null} to + */ + function recordInRange(record, from, to) { + if (!isCount(from) && !isCount(to)) return true; + var at = record && record.relativeMs; + if (!isCount(at)) return false; + if (isCount(from) && at < from) return false; + if (isCount(to) && at > to) return false; + return true; + } + + /** `0:30 – 1:00`, `开始 – 1:00`, `0:30 – 现在`. Empty when neither end is set. */ + function rangeLabel(from, to) { + if (!isCount(from) && !isCount(to)) return ''; + return (isCount(from) ? formatRangeOffset(from) : '开始') + + ' – ' + + (isCount(to) ? formatRangeOffset(to) : '现在'); + } + /** Absolute local wall-clock time. */ function formatClock(ts) { if (!isCount(ts) || ts <= 0) return DASH; @@ -2347,6 +2503,45 @@

检视

PURE:END ============================================================ */ + /* ---------------- time range, bound to state ---------------- + The parsing and the comparison above are pure so the offline probe can test them + without a DOM; these three are the same rules reading the live filter. */ + + function hasTimeRange() { + return isCount(state.rangeFrom) || isCount(state.rangeTo); + } + + function inTimeRange(record) { + if (!recordInRange(record, state.rangeFrom, state.rangeTo)) return false; + // The snapshot half of the range. Without it an open-ended range grows on its own and + // the table appends itself every 2.5s. + if (isCount(state.rangeCutoff) && isCount(record.index) && record.index > state.rangeCutoff) { + return false; + } + return true; + } + + /** The highest record index the session currently holds. */ + function latestRecordIndex() { + var top = 0; + for (var i = 0; i < state.flat.length; i += 1) { + var index = state.flat[i] && state.flat[i].record ? state.flat[i].record.index : null; + if (isCount(index) && index > top) top = index; + } + return top; + } + + /** Records written since the range was set — the ones 更新到最新 would bring in. */ + function recordsPastCutoff() { + if (!isCount(state.rangeCutoff)) return 0; + var top = latestRecordIndex(); + return top > state.rangeCutoff ? top - state.rangeCutoff : 0; + } + + function timeRangeLabel() { + return rangeLabel(state.rangeFrom, state.rangeTo); + } + /* ---------------- DOM helpers ---------------- */ var SVG_NS = 'http://www.w3.org/2000/svg'; @@ -2462,6 +2657,29 @@

检视

// to explain. kinds: KIND_ORDER.slice(), query: '', + // Time range, in milliseconds since the session started — the same zero the ledger's + // `+893 分 21 秒` column counts from. `null` on either side means that end is open, + // and both null is the default: no range, whole session. + // + // This is the one filter the KPI follows. The type chips and the search box narrow + // what is *drawn*; a range answers a different question — "what happened in this + // stretch" — and a headline count for the whole session beside a table of the range + // is the mismatch this state exists to remove. + rangeFrom: null, + rangeTo: null, + // The highest record index in force when the range was set. An open-ended range is + // `开始 – 现在`, and 「现在」 moves on its own: every record the session writes from + // then on lands inside the range and appended itself to the table and the KPI. That is + // the auto-append this filter was asked not to do, and it was measured — 11 new + // records moved 「已显示 588」 to 「已显示 599」 with nobody touching anything. + // + // So a range is a snapshot, not a subscription. The cutoff is what makes it one, and + // it moves only when the reader asks: editing either box, or 更新到最新. + rangeCutoff: null, + // The last value the page could read. A half-typed `1:2` must not silently blank the + // range and hand the reader the whole session back; the hint says which one is live. + rangeFromText: '', + rangeToText: '', limit: PAGE_STEP, // 「全部」 is a mode, not just a big `limit`. A poll that arrives mid-session used to // re-pin the window to the newest page, which would have thrown 「全部」 back to 200 @@ -2491,6 +2709,7 @@

检视

var sessionsInflight = null; var pollTimer = null; var searchTimer = null; + var rangeTimer = null; var refreshSeq = 0; function cacheDom() { @@ -2509,6 +2728,11 @@

检视

dom.generationSelect = document.getElementById('generation-select'); dom.generationHint = document.getElementById('generation-hint'); dom.searchInput = document.getElementById('search-input'); + dom.rangeFrom = document.getElementById('range-from'); + dom.rangeTo = document.getElementById('range-to'); + dom.rangeClear = document.getElementById('range-clear'); + dom.rangeRefresh = document.getElementById('range-refresh'); + dom.rangeHint = document.getElementById('range-hint'); dom.windowBar = document.getElementById('window-bar'); dom.headerToggle = document.getElementById('header-toggle'); dom.kpiToggle = document.getElementById('kpi-toggle'); @@ -2925,10 +3149,134 @@

检视

return '第 ' + formatInt(firstTurn) + '–' + formatInt(lastTurn) + ' 轮'; } + /** + * The same `stats` shape the server sends, recomputed over the records inside the time + * range. + * + * The server builds its numbers as a by-product of splitting the file into records, so + * they cannot be asked for a subset. Re-deriving them here rather than adding a query + * parameter is what keeps the headline and the table from being able to disagree: both + * read `state.flat`, so a row on screen is a row that was counted. + * + * Two populations, deliberately. Record-level figures (消息, 工具调用, Token) count only + * the records inside the range — that is what "what happened in this stretch" means. Turn + * -level figures count a turn when *any* of its records is in range and then classify the + * whole turn, because how a turn ended does not change because the range cuts through it. + * The scope label says 涉及 N 轮 for exactly this reason. + * + * `messages` counts distinct record ids, not records: one file line splits into several + * rows (thinking, then one per tool call), and the server's count is of lines. Counting + * rows here would have put 11,334 on a card that reads 8,088 today. + * + * @returns {any|null} null when there is no payload to scope + */ + function scopedStats() { + var payload = state.payload; + var base = payload && payload.stats ? payload.stats : null; + if (!base) return null; + var stats = { + messages: 0, + userMessages: 0, + toolCalls: 0, + toolResults: 0, + thinkingBlocks: 0, + errors: 0, + compactions: 0, + turnsWithoutPrompt: 0, + turnsWithPrompt: 0, + continuationTurns: 0, + qaTurns: 0, + openTurns: 0, + compactionTurns: 0, + // Whole-session figures, kept whole: 「全程 N」 beside the range is the comparison the + // reader actually wants, and re-scoping it would leave the note with nothing to say. + messagesAll: base.messagesAll, + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, + durationMs: null + }; + // `Object.create(null)` rather than `{}`: these are keyed by a string that came out of + // the file, and an id of `constructor` or `toString` would otherwise read as already + // seen and silently drop its message from the count. + var messages = Object.create(null); + var errors = Object.create(null); + var touched = Object.create(null); + var first = null; + var last = null; + + for (var i = 0; i < state.flat.length; i += 1) { + var entry = state.flat[i]; + var record = entry.record; + if (!inTimeRange(record)) continue; + var id = record.id; + // A string, not a number: `isCount` here would have reported 0 messages for every + // session, which is what a whole-range check against the server caught. + if (typeof id === 'string' && id !== '' && !messages[id]) { + messages[id] = true; + stats.messages += 1; + } + // The server counts an error once per file line, so this de-duplicates the same way + // rather than counting every record a line was split into. + if (record.isError === true && typeof id === 'string' && id !== '' && !errors[id]) { + errors[id] = true; + stats.errors += 1; + } + var kind = record.kind; + if (kind === 'user') stats.userMessages += 1; + else if (kind === 'steer') stats.steers += 1; + else if (kind === 'thinking') stats.thinkingBlocks += 1; + else if (kind === 'compaction') stats.compactions += 1; + else if (kind === 'toolCall') stats.toolCalls += 1; + else if (kind === 'toolResult') stats.toolResults += 1; + var tokens = record.tokens; + if (tokens && typeof tokens === 'object') { + if (isCount(tokens.input)) stats.tokens.input += tokens.input; + if (isCount(tokens.output)) stats.tokens.output += tokens.output; + if (isCount(tokens.cacheRead)) stats.tokens.cacheRead += tokens.cacheRead; + if (isCount(tokens.cacheWrite)) stats.tokens.cacheWrite += tokens.cacheWrite; + if (isCount(tokens.totalTokens)) stats.tokens.totalTokens += tokens.totalTokens; + } + if (isCount(record.relativeMs)) { + if (first === null || record.relativeMs < first) first = record.relativeMs; + if (last === null || record.relativeMs > last) last = record.relativeMs; + } + if (entry.turn && isCount(entry.turn.index)) touched[entry.turn.index] = true; + } + + var turns = Array.isArray(payload.turns) ? payload.turns : []; + for (var t = 0; t < turns.length; t += 1) { + var turn = turns[t]; + if (!turn || !touched[turn.index]) continue; + // Mirrors the server's classifier: prompt, then continuation, then a turn that is + // nothing but a checkpoint, then the rest. + if (turn.turnClass === 'prompt') { + stats.turnsWithPrompt += 1; + if (turn.closed !== true) stats.openTurns += 1; + if (turn.askedReader === true && turn.closed !== true) stats.qaTurns += 1; + } else if (turn.turnClass === 'continuation') { + stats.continuationTurns += 1; + } else if (turn.turnClass === 'compaction') { + stats.compactionTurns += 1; + } else { + stats.turnsWithoutPrompt += 1; + } + } + + stats.durationMs = first === null ? null : last - first; + return stats; + } + + /** The numbers the cards read: the range's, when one is set, otherwise the server's. */ + function kpiStats() { + if (!hasTimeRange()) { + return state.payload && state.payload.stats ? state.payload.stats : null; + } + return scopedStats(); + } + function renderKpi() { var grid = dom.kpiGrid; clear(grid); - var stats = state.payload && state.payload.stats ? state.payload.stats : null; + var stats = kpiStats(); if (!stats) { grid.appendChild(kpiCard('消息', DASH, '正在读取…', true)); @@ -3719,6 +4067,7 @@

检视

// The overview brush is a further filter, applied after kind and text so that // clearing it never silently changes what the other two meant. if (selection && !recordInSelection(entry.record, selection, overviewSpansById)) continue; + if (!inTimeRange(entry.record)) continue; out.push(entry); } return out; @@ -3920,6 +4269,7 @@

检视

// of a hidden layer — the exact mismatch this function exists to prevent. return state.query !== '' || state.kinds.length !== KIND_ORDER.length || state.overviewSelection !== null + || hasTimeRange() ; } @@ -3933,6 +4283,10 @@

检视

function spanPassesFilter(record, hasQuery) { if (!hasKind(record, state.kinds)) return false; if (hasQuery && !matchesQuery(record, state.query)) return false; + // The range is a filter here too. Leaving it out made the overview draw spans for + // records the list refused to render — the same "filtered but nothing changed" this + // function exists to prevent. + if (!inTimeRange(record)) return false; return true; } @@ -4061,6 +4415,10 @@

检视

*/ function rePinWindow() { if (state.allRecords) return state.limit; + // A time range is a fixed stretch of the session, so the window follows it rather + // than the tail. Re-pinning would slide the whole range forward every 2.5s, and the + // records that scrolled off the top would be records the reader had scoped to. + if (hasTimeRange()) return state.limit; if (state.limit > PAGE_STEP) return state.limit; state.limit = Math.min(PAGE_STEP, state.flat.length) || PAGE_STEP; return state.limit; @@ -4406,7 +4764,73 @@

检视

* through here, kind buttons and search alike. */ function applyFilterScope() { - if (dom.kpiScope) dom.kpiScope.hidden = !isFiltering(); + if (!dom.kpiScope) return; + if (hasTimeRange()) { + var stats = kpiStats(); + var scope = '已按区间 ' + timeRangeLabel() + ' 统计'; + if (stats && isCount(stats.messages)) { + scope += ' · ' + formatInt(stats.messages) + ' 条消息 · 涉及 ' + + formatInt(stats.turnsWithPrompt) + ' 轮'; + } + dom.kpiScope.textContent = scope; + dom.kpiScope.hidden = false; + return; + } + // The old wording was 「不随筛选变化」, which stopped being true the day a filter that + // the KPI *does* follow existed. It now names the filters that only narrow the list. + dom.kpiScope.textContent = '整场会话 · 类型与搜索只筛列表'; + dom.kpiScope.hidden = !isFiltering(); + } + + /** + * What the two boxes currently mean. + * + * Three states, and the reader has to be able to tell them apart without guessing: no + * range, a range in force, and a half-typed value the page could not read. The last one + * keeps the previous range rather than blanking it — silently handing back the whole + * session mid-edit is the worst of the three — and says so here. + */ + function updateRangeHint() { + if (!dom.rangeHint) return; + // Read the *boxes*, not the filter. An unreadable value is deliberately never written + // into state — that is what stops it switching the range off mid-keystroke — so + // testing `state.rangeTo` for NaN here could only ever be false, and the message the + // reader most needs became unreachable. Typing `abc` reported 「起点晚于终点」, + // which is true of the *previous* range and says nothing about what they just typed. + var fromUnreadable = state.rangeFromText.trim() !== '' + && isNaN(parseRangeOffset(state.rangeFromText)); + var toUnreadable = state.rangeToText.trim() !== '' + && isNaN(parseRangeOffset(state.rangeToText)); + var reversed = isCount(state.rangeFrom) && isCount(state.rangeTo) && state.rangeFrom > state.rangeTo; + dom.rangeHint.classList.remove('is-invalid'); + + if (fromUnreadable || toUnreadable) { + dom.rangeHint.classList.add('is-invalid'); + dom.rangeHint.textContent = '格式无法识别,仍按上一次的有效区间统计' + + (hasTimeRange() ? '(' + timeRangeLabel() + ')' : '') + + '。格式 1:30 或 1:30:00。'; + return; + } + if (reversed) { + dom.rangeHint.classList.add('is-invalid'); + dom.rangeHint.textContent = '起点晚于终点,没有记录落在这段时间里。'; + return; + } + if (!hasTimeRange()) { + dom.rangeHint.classList.remove('is-invalid'); + dom.rangeHint.textContent = '留空 = 不限。填写会话开始后的时间,格式 1:30 或 1:30:00。'; + return; + } + var stats = kpiStats(); + var behind = recordsPastCutoff(); + dom.rangeHint.classList.remove('is-invalid'); + dom.rangeHint.textContent = '区间 ' + timeRangeLabel() + ' · 关键指标已按此区间统计' + + (stats && isCount(stats.messages) ? ' · ' + formatInt(stats.messages) + ' 条消息' : '') + // Said out loud because the range is a snapshot: without it a frozen view looks + // broken rather than deliberate, and the reader assumes the page stopped updating. + + (behind > 0 + ? ' · 已固定在设定时的样子,之后新增的 ' + formatInt(behind) + ' 条未计入' + : ' · 已固定在设定时的样子,新增记录不会自动追加'); } /* ---------------- selection ---------------- */ @@ -4423,12 +4847,95 @@

检视

function clearFilters() { state.kinds = KIND_ORDER.slice(); state.query = ''; + clearTimeRange(); resetWindowMode(); if (dom.searchInput) dom.searchInput.value = ''; syncKindButtons(); render(); } + /** + * Drops the range. Does not render, so `clearFilters` and the button can share it. + */ + function clearTimeRange() { + state.rangeFrom = null; + state.rangeTo = null; + state.rangeFromText = ''; + state.rangeToText = ''; + state.rangeCutoff = null; + if (dom.rangeFrom) dom.rangeFrom.value = ''; + if (dom.rangeTo) dom.rangeTo.value = ''; + syncRangeButtons(); + updateRangeHint(); + } + + /** + * Reads both boxes into the filter. + * + * A box the page could not read is left alone rather than assigned: `NaN` fails the + * `isCount` test behind `hasTimeRange`, so writing it into state would switch the whole + * range off — handing back the entire session in the middle of typing `1:2`. The hint + * says which value is live. + */ + function applyTimeRange() { + var from = parseRangeOffset(state.rangeFromText); + var to = parseRangeOffset(state.rangeToText); + if (!isNaN(from)) state.rangeFrom = from; + if (!isNaN(to)) state.rangeTo = to; + + if (hasTimeRange()) { + // Re-cut the snapshot on every apply, not only on the transition. Editing either box + // is the reader saying what they want to see now, so it must also pick up what has + // arrived since; a range they did not touch stays where it was. + state.rangeCutoff = latestRecordIndex(); + } + syncRangeButtons(); + updateRangeHint(); + + // Like every other filter, and for the same reason: the window the reader was looking + // at was chosen for a set of records that no longer exists. Setting a range used to + // force 「全部」 instead, on the theory that a 200-cap inside a range silently drops + // the start of what the reader asked for. Measured, that theory is not worth its + // price — a range starting at 20:00 covers the whole session, 全部 then rendered + // ~11,000 rows and took the document to 414,000px, where one query took 19s. The + // window bar still says how many are not loaded and 「显示全部」 is still one click. + // + // 不自动追加 does not depend on this: the snapshot cutoff holds the range still on + // its own, which is what it was measured doing. + resetWindowMode(); + render(); + } + + /** The clear and refresh buttons, which exist only while a range does. */ + function syncRangeButtons() { + var isSet = hasTimeRange(); + if (dom.rangeClear) dom.rangeClear.hidden = !isSet; + if (dom.rangeRefresh) { + dom.rangeRefresh.hidden = !isSet; + // Disabled, not hidden, when there is nothing to bring in. How many records are + // waiting changes on every poll, and a control that appears and disappears takes + // its neighbour with it: 清空区间 shifted sideways whenever this one showed up, and + // clicks aimed at it landed on empty space. The range was open-ended, so it showed + // up every time. A disabled button keeps its box, so nothing moves. + dom.rangeRefresh.disabled = !isSet || recordsPastCutoff() === 0; + } + } + + /** + * Brings the snapshot up to what the session has written since. + * + * It exists because the range is deliberately a snapshot. Without a way to move it, a + * reader would have to retype a box to see the last two minutes, which is a worse trap + * than the auto-append it replaced. + */ + function refreshTimeRange() { + if (!hasTimeRange()) return; + state.rangeCutoff = latestRecordIndex(); + syncRangeButtons(); + updateRangeHint(); + render(); + } + /* ---------------- inspector ---------------- */ var TABS = [ @@ -4920,6 +5427,15 @@

检视

renderBars(); renderGeneration(); renderKpi(); + // Both read the range, so they cannot wait for the ledger to call them: the ledger + // returns early while loading, which is exactly when a stale scope line would sit + // above cards that had already changed. + applyFilterScope(); + updateRangeHint(); + // The refresh button depends on how far the session has run past the snapshot, which + // changes on every poll — it has to be re-decided here or it would offer to refresh + // for records that are already in, or stay hidden while 200 records pile up behind it. + syncRangeButtons(); renderOverview(); renderLedger(); renderInspector(); @@ -5165,6 +5681,33 @@

检视

event.preventDefault(); }); + // Both boxes feed one filter, so one debounce covers editing either of them: `1:2` + // resolves to nothing and `1:20` resolves to a range, and a reader who pauses mid + // keystroke should not see the table jump through the whole session in between. + var onRangeInput = function () { + state.rangeFromText = dom.rangeFrom.value; + state.rangeToText = dom.rangeTo.value; + if (rangeTimer !== null) window.clearTimeout(rangeTimer); + rangeTimer = window.setTimeout(function () { + rangeTimer = null; + applyTimeRange(); + }, SEARCH_DEBOUNCE); + }; + dom.rangeFrom.addEventListener('input', onRangeInput); + dom.rangeTo.addEventListener('input', onRangeInput); + dom.rangeRefresh.addEventListener('click', function () { + if (rangeTimer !== null) window.clearTimeout(rangeTimer); + rangeTimer = null; + refreshTimeRange(); + }); + dom.rangeClear.addEventListener('click', function () { + if (rangeTimer !== null) window.clearTimeout(rangeTimer); + rangeTimer = null; + clearTimeRange(); + resetWindowMode(); + render(); + }); + var kindButtons = dom.kindControls.querySelectorAll('.tbtn-action'); for (var j = 0; j < kindButtons.length; j += 1) { if (kindButtons[j].id === 'kind-all') continue; From 7ed705531b2e9da34675c5cb1a65787e2087348d Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 11:37:04 +0800 Subject: [PATCH 15/19] feat(mmc-trajectory): let the time range be a wall clock, not only an offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boxes took a session-relative offset, which is what the ledger prints on every row — and which stops being answerable once a session runs past midnight. This one crosses 2026-10-11 at offset 12:14:29: `12:00:00` is 2026-10-10 23:45:31 and `13:00:00` is 2026-10-11 00:45:31, so an hour of offset covers three quarters of an hour of clock with midnight in the middle and nothing on the page saying so. A reader asking for 「23:50 到 00:20」 had no way to ask it. So a box may now hold either clock, and the text decides which. - The two grammars cannot be confused because they share no separator: an offset is digits and colons (`1:30`, `1:30:00`), a date is digits and `-` or `/`. `hasRangeDate` reads that off the text before any value is computed, so the filter, the label and the hint cannot come out disagreeing about what was typed. - The conversion is a subtraction and nothing more. Every record already carries `timestamp` and `relativeMs`, and `session.startedAt` is the origin those offsets were measured from, so no second clock is introduced anywhere. Checked against the live payload: a date range over 23:50–00:20 selects exactly the 545 records whose `timestamp` falls in it, and the equivalent offset range `12:04:28 – 12:34:28` selects the same 545. - The year is required rather than inferred. Guessing means deciding what `01-01` means in a session that starts in December, and a range that lands on the wrong year is worse than one the reader has to finish typing. - The date is assembled from parts and round-tripped, never handed to `Date` as a string: `new Date('2026-10-10')` is UTC, and without the round trip `2026-02-30` rolls silently into March and returns a range nobody asked for. - A bound before the session began stays negative instead of being clamped, and now prints its sign — `Math.abs` on its own made `-2:45:00` read as `2:45:00`, which in the one place the sign carries the whole message is the wrong answer rather than a formatting nit. - Each end of the label comes back in the clock its own box was written in. An offset beside a date is a mismatch the reader has to decode before trusting it. - A session whose records carry no timestamp says so, instead of reporting 「格式无法识别」 and sending the reader hunting for a typo that is not there. - The boxes were a bare 88px, which held `2026-10-10` and scrolled the time out of sight. The value stayed correct and the hint echoed it back, but the field could not be checked by looking at it — the same defect as the 序号 column. Width is now 18ch, derived from the rendered box: 15ch came out at 113px and held 15 character slots, and sixteen glyphs plus the caret is 17. Probes: 162 assertions in verify-time-range.mjs, and 55 + 12 mutations in the two runners, each caught by the guard that owns the defect. --- .../mmc-trajectory/miniapp/client/index.html | 194 ++++++++++++++++-- 1 file changed, 175 insertions(+), 19 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index b3cacec..a16c891 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -431,7 +431,21 @@ } .range-input { - width: 88px; + /* Wide enough for the longest thing the hint tells the reader to type here — + `2026-10-10 23:30`, 16 characters — plus the caret. It was a bare 88px, which held + `2026-10-10` and scrolled the rest out of sight: the value stayed correct and the + hint echoed it back, but the box itself showed a substring, so the field could not + be checked by looking at it. Same failure as the record-index column — a width fixed + by today's content rather than by the content the control accepts. + + Measured, not guessed. `ch` is the advance of a digit, and `tabular-nums` makes + every digit in a date exactly one `ch`, so the width scales with the font instead of + being a number measured once on one machine. At 15ch this box rendered 113px and + held 15 character slots — 14 glyphs and the caret — with `2026-10-10 23:50` still + losing its leading `20`. Sixteen glyphs and a caret is 17 slots, so 17ch is the + floor and 18ch leaves a margin. */ + width: 18ch; + min-width: 18ch; font-variant-numeric: tabular-nums; } @@ -2180,14 +2194,18 @@

检视

} /* ---------------- time range ---------------- - Offsets in milliseconds since the session started. This is the one clock the ledger - already prints on every row, so the number a reader is looking at can be typed - straight in; a wall-clock field would have matched nothing else on the page. + Milliseconds since the session started. Offsets are the default because they are the + one clock the ledger already prints on every row, so a number a reader is looking at can + be typed straight in — but a session that runs past midnight outlives that. This one + crosses 2026-10-11 at offset 12:14:29, and there `12:00:00` is last night while + `13:00:00` is a quarter of an hour later: an hour of offset, fifteen minutes of wall + clock, and midnight inside it with nothing on the page saying so. So a box may also + hold a wall clock, and `hasRangeDate` decides which from the text alone. Three outcomes, kept apart on purpose: null → empty, that end is open (or the whole filter is off) NaN → the text is not a time; the filter keeps its last readable value - >= 0 → the offset in ms + integer → the offset in ms, negative when the bound precedes session start `1:30` and `1:30:00` are both read as minutes and minutes:seconds, matching the `893 分 21 秒` the row shows. Bare digits are minutes, because "the first ten @@ -2227,18 +2245,109 @@

检视

return seconds * 1000; } + /** + * Whether a box is a wall-clock date rather than an offset. + * + * The two grammars cannot be confused because they share no separator: an offset is + * digits and colons (`1:30`, `1:30:00`), a date is digits and `-` or `/`. So this is a + * property of what the reader typed, decided before any value is computed — the filter, + * the label and the hint all go through it and cannot come out disagreeing. + * + * @param {string} text + * @returns {boolean} + */ + function hasRangeDate(text) { + var raw = String(text === null || text === undefined ? '' : text).trim(); + if (raw === '') return false; + // The day/time part is optional, so `2026-10-10` is a date and reads as that midnight. + return /^\d{4}[-/]\d{1,2}[-/]\d{1,2}([T ]+\d{1,2}(:\d{1,2}(:\d{1,2})?)?)?$/.test(raw); + } + + /** + * A wall-clock bound, returned as an offset from session start. + * + * Subtracting is the whole conversion: every record already carries both `timestamp` and + * `relativeMs`, and `session.startedAt` is the same origin the offsets were measured + * from, so no new clock is introduced anywhere. + * + * The result may be negative, and deliberately so. A session that began at 23:45 today + * has no records at 20:00, and a bound there means exactly that — every record is past + * it. Clamping to zero would also work and would be wrong to notice, but printing + * `-2:45:00` next to the range is what makes it visible instead. + * + * The year is required rather than inferred. Guessing turns `01-01` into a coin flip in + * a session that starts in December, and a range that silently lands on the wrong year + * is worse than one the reader has to finish typing. + * + * @param {string} text + * @param {number|null} sessionStartMs + * @returns {number|null} offset ms, `null` when empty, `NaN` when the text is not a date + * or the session has no start to measure from + */ + function parseRangeAbsolute(text, sessionStartMs) { + var raw = String(text === null || text === undefined ? '' : text).trim(); + if (raw === '') return null; + if (!hasRangeDate(raw)) return NaN; + if (!isCount(sessionStartMs)) return NaN; + var parts = raw.split(/[T ]+/); + var dateParts = parts[0].split(/[-\/]/); + var year = Number(dateParts[0]); + var month = Number(dateParts[1]); + var day = Number(dateParts[2]); + var hour = 0; + var minute = 0; + var second = 0; + if (parts.length > 1) { + var clock = parts[1].split(':'); + if (clock.length === 2) { + hour = Number(clock[0]); + minute = Number(clock[1]); + } else if (clock.length === 3) { + hour = Number(clock[0]); + minute = Number(clock[1]); + second = Number(clock[2]); + } else return NaN; + } + // Built from parts, never parsed from the string: `new Date('2026-10-10')` is UTC and + // would silently shift the bound by the offset between here and Greenwich. The + // round-trip below is what rejects 2026-02-30, which Date otherwise rolls forward + // into March and hands back a range the reader never asked for. + var wall = new Date(year, month - 1, day, hour, minute, second, 0); + if (isNaN(wall.getTime())) return NaN; + if (wall.getFullYear() !== year || wall.getMonth() !== month - 1 || wall.getDate() !== day + || wall.getHours() !== hour || wall.getMinutes() !== minute || wall.getSeconds() !== second) return NaN; + return wall.getTime() - sessionStartMs; + } + + /** + * One box, either grammar. The single place that decides which, so nothing downstream can + * treat a wall clock as an offset or the reverse. + * + * @param {string} text + * @param {number|null} sessionStartMs + * @returns {number|null} offset ms, `null` when empty, `NaN` when unreadable + */ + function parseRangeValue(text, sessionStartMs) { + return hasRangeDate(text) ? parseRangeAbsolute(text, sessionStartMs) : parseRangeOffset(text); + } + /** * @param {number} ms - * @returns {string} `1:30:00`, or `1:30` while it fits in minutes + * @returns {string} `1:30:00`, or `1:30` while it fits in minutes; `-` before a bound that + * lands before the session began */ function formatRangeOffset(ms) { if (!isCount(ms)) return DASH; + // An absolute bound can land before session start, and `Math.abs` alone would print + // `-2:45:00` as `2:45:00` — the same string a reader would get for a bound two hours + // and forty-five minutes *in*, in the one place the sign is the whole message. + var sign = ms < 0 ? '-' : ''; var total = Math.floor(Math.abs(ms) / 1000); var hours = Math.floor(total / 3600); var minutes = Math.floor((total % 3600) / 60); var seconds = total % 60; - if (hours > 0) return hours + ':' + pad2(minutes) + ':' + pad2(seconds); - return minutes + ':' + pad2(seconds); + if (hours > 0) return sign + hours + ':' + pad2(minutes) + ':' + pad2(seconds); + return sign + minutes + ':' + pad2(seconds); } /** @@ -2264,12 +2373,31 @@

检视

return true; } - /** `0:30 – 1:00`, `开始 – 1:00`, `0:30 – 现在`. Empty when neither end is set. */ - function rangeLabel(from, to) { + /** + * `0:30 – 1:00`, `开始 – 1:00`, `0:30 – 现在`. Empty when neither end is set. + * + * Each end is printed back in the form the reader typed it in: a box holding a date comes + * back as a wall clock, a box holding an offset stays an offset. Printing an offset for a + * date box would be the worst of both — `12:00` beside `2026-10-11 00:45` is a mismatch + * the reader has to decode before they can trust it — and printing a wall clock for an + * offset box would hide the fact that the box is measured from session start, not from + * midnight. + * + * @param {string} fromText @param {string} toText what each box holds, verbatim + * @param {number|null} from @param {number|null} to offsets in force + * @param {number|null} sessionStartMs + */ + function rangeLabel(fromText, toText, from, to, sessionStartMs) { if (!isCount(from) && !isCount(to)) return ''; - return (isCount(from) ? formatRangeOffset(from) : '开始') + return (isCount(from) ? rangeEndLabel(fromText, from, sessionStartMs) : '开始') + ' – ' - + (isCount(to) ? formatRangeOffset(to) : '现在'); + + (isCount(to) ? rangeEndLabel(toText, to, sessionStartMs) : '现在'); + } + + /** One end of the range label, in whichever clock its own box was written in. */ + function rangeEndLabel(text, ms, sessionStartMs) { + if (hasRangeDate(text) && isCount(sessionStartMs)) return formatClock(ms + sessionStartMs); + return formatRangeOffset(ms); } /** Absolute local wall-clock time. */ @@ -2538,8 +2666,20 @@

检视

return top > state.rangeCutoff ? top - state.rangeCutoff : 0; } + /** + * The session's own start — the origin every offset is measured from, and the only thing a + * wall-clock bound can be converted against. Null when the payload has none, which is the + * one case where a date box cannot be honoured at all. + */ + function sessionStartedAt() { + var session = state.payload && state.payload.session ? state.payload.session : null; + var started = session ? session.startedAt : null; + return isCount(started) ? started : null; + } + function timeRangeLabel() { - return rangeLabel(state.rangeFrom, state.rangeTo); + return rangeLabel(state.rangeFromText, state.rangeToText, state.rangeFrom, state.rangeTo, + sessionStartedAt()); } /* ---------------- DOM helpers ---------------- */ @@ -4797,18 +4937,30 @@

检视

// testing `state.rangeTo` for NaN here could only ever be false, and the message the // reader most needs became unreachable. Typing `abc` reported 「起点晚于终点」, // which is true of the *previous* range and says nothing about what they just typed. + var base = sessionStartedAt(); var fromUnreadable = state.rangeFromText.trim() !== '' - && isNaN(parseRangeOffset(state.rangeFromText)); + && isNaN(parseRangeValue(state.rangeFromText, base)); var toUnreadable = state.rangeToText.trim() !== '' - && isNaN(parseRangeOffset(state.rangeToText)); + && isNaN(parseRangeValue(state.rangeToText, base)); + // A perfectly good date that this session cannot measure from. Folding it into the + // "unrecognised format" branch would send the reader hunting for a typo that is not + // there, and worse, it is the only way a date silently fails — silently is the part that + // matters, since everything else about the page would look like it had been filtered. + var dateWithoutBase = base === null + && (hasRangeDate(state.rangeFromText) || hasRangeDate(state.rangeToText)); var reversed = isCount(state.rangeFrom) && isCount(state.rangeTo) && state.rangeFrom > state.rangeTo; dom.rangeHint.classList.remove('is-invalid'); + if (dateWithoutBase) { + dom.rangeHint.classList.add('is-invalid'); + dom.rangeHint.textContent = '这个会话没有记录带时间戳,无法按日期筛选;偏移 1:30 仍然可用。'; + return; + } if (fromUnreadable || toUnreadable) { dom.rangeHint.classList.add('is-invalid'); dom.rangeHint.textContent = '格式无法识别,仍按上一次的有效区间统计' + (hasTimeRange() ? '(' + timeRangeLabel() + ')' : '') - + '。格式 1:30 或 1:30:00。'; + + '。偏移写 1:30 或 1:30:00,日期写 2026-10-10 23:30。'; return; } if (reversed) { @@ -4818,7 +4970,8 @@

检视

} if (!hasTimeRange()) { dom.rangeHint.classList.remove('is-invalid'); - dom.rangeHint.textContent = '留空 = 不限。填写会话开始后的时间,格式 1:30 或 1:30:00。'; + dom.rangeHint.textContent = '留空 = 不限。填会话开始后的时间 1:30 / 1:30:00,' + + '或日期时间 2026-10-10 23:30(跨零点时用这个)。'; return; } var stats = kpiStats(); @@ -4878,8 +5031,11 @@

检视

* says which value is live. */ function applyTimeRange() { - var from = parseRangeOffset(state.rangeFromText); - var to = parseRangeOffset(state.rangeToText); + // One origin for both boxes, read once: two boxes resolved against two different starts + // would be a range whose width depends on which side was parsed last. + var base = sessionStartedAt(); + var from = parseRangeValue(state.rangeFromText, base); + var to = parseRangeValue(state.rangeToText, base); if (!isNaN(from)) state.rangeFrom = from; if (!isNaN(to)) state.rangeTo = to; From dc779531f6acc27921059adef0709e5959e06953 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 11:57:28 +0800 Subject: [PATCH 16/19] feat(mmc-trajectory): pick the range on a calendar, and drop the offset grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boxes took a session-relative offset, on the argument that `+893 分 21 秒` is what every ledger row prints. They are now two `datetime-local` pickers with four quick buttons beside them, and the offset grammar is gone. The argument did not survive contact with a session that crosses midnight. This one crosses 2026-10-11 at offset 12:14:29, so `12:00:00` is 2026-10-10 23:45:31 and `13:00:00` is 2026-10-11 00:45:31 — an hour of offset covering three quarters of an hour of clock, with the midnight in between and nothing on the page saying so. It also did not survive switching sessions, where the same number means a different moment. A picker is the honest control for a moment in time, and one grammar per box is one less thing the reader has to work out. The quick picks are the four windows this page can answer for any session: - 最近 15 分钟 / 最近 1 小时 — the trailing history a reader wants when something just happened - 今天 / 昨天 — the two wall-clock days a session that runs overnight is split across 清空区间 already means the whole session, so there is deliberately no 全程 button beside them. Days are stepped through the `Date` constructor rather than by subtracting 86,400,000, which lands on the wrong day across a daylight-saving boundary, and a pick whose window opens before the session is clamped to the session start — "the last fifteen minutes" of a five-minute-old session is its whole life, not a negative range. Two things the picker could not do on its own: - A window that is well formed and lies outside the session now says 「这段时间里没有记录」 and names where the session actually starts. That is exactly what 昨天 does on a session that began this morning, and without it an empty table reads as 「这个会话没有内容」. - The pickers are bounded by the span the session covers, re-decided on every render because the session grows during the first poll. The controls also had to stop being the odd one out: `datetime-local` was missing from the shared input rule, so it arrived at its own intrinsic 22px height with the user agent's own border. And it is now sized by its own content rather than by a number in the stylesheet — the same defect as the 序号 column, committed twice: 18ch clipped the year off the text box that preceded it, and 20ch clipped the minutes behind the calendar icon on the picker. The browser lays a `datetime-local` out from the localized date it has to draw, so a width in the stylesheet is a guess about something the user agent already knows, and it cannot go stale the way a measured constant does. Probes: 148 assertions in verify-time-range.mjs, and 66 + 11 mutations across the two runners, each caught by the guard that owns the defect. --- .../mmc-trajectory/miniapp/client/index.html | 483 ++++++++++-------- 1 file changed, 272 insertions(+), 211 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index a16c891..220a065 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -292,6 +292,11 @@ input[type='search'], input[type='text'], + /* The range pickers sit in the same row as the search box and the selects, and a native + control left out of this list arrives at its own intrinsic 22px height with the user + agent's own border — visibly not part of the toolbar, and out of step with + `color-scheme` on both themes. */ + input[type='datetime-local'], select { min-height: 36px; max-width: 100%; @@ -412,11 +417,23 @@ width: 100%; } - /* The time range is measured from the start of the session, because that is the only - clock the ledger already prints. The `+893 分 21 秒` column, the overview axis and the - hint under these inputs are all offsets from the same zero, so a reader can copy the - number they are looking at straight into 起点. A wall-clock picker would have been - the more obvious control and would have matched nothing else on the page. */ + /* The range is wall-clock, on two native pickers. + + It used to be two text boxes taking a session-relative offset, on the argument that + `+893 分 21 秒` is what every ledger row prints. That argument does not survive a + session that runs past midnight: this one crosses 2026-10-11 at offset 12:14:29, where + `12:00:00` is 23:45 the night before and `13:00:00` is 00:45 — an hour of offset for + three quarters of an hour of clock, with the midnight in between and nothing on the + page saying so. It also does not survive switching sessions, where the same number + means a different moment. A picker is the honest control for a moment in time, and it + removes the reader's job of working out which of two grammars they are typing. */ + .field-range { + /* The quick picks are wide enough that they need somewhere to go when the row runs + out of width. Wrapping puts them on their own line instead of pushing 更新到最新 + and 清空区间 off the edge of the toolbar. */ + flex-wrap: wrap; + } + .range-part { display: inline-flex; align-items: center; @@ -431,24 +448,33 @@ } .range-input { - /* Wide enough for the longest thing the hint tells the reader to type here — - `2026-10-10 23:30`, 16 characters — plus the caret. It was a bare 88px, which held - `2026-10-10` and scrolled the rest out of sight: the value stayed correct and the - hint echoed it back, but the box itself showed a substring, so the field could not - be checked by looking at it. Same failure as the record-index column — a width fixed - by today's content rather than by the content the control accepts. - - Measured, not guessed. `ch` is the advance of a digit, and `tabular-nums` makes - every digit in a date exactly one `ch`, so the width scales with the font instead of - being a number measured once on one machine. At 15ch this box rendered 113px and - held 15 character slots — 14 glyphs and the caret — with `2026-10-10 23:50` still - losing its leading `20`. Sixteen glyphs and a caret is 17 slots, so 17ch is the - floor and 18ch leaves a margin. */ - width: 18ch; - min-width: 18ch; + /* No width at all, on purpose. + + A `datetime-local` is laid out by the browser from the localized date it has to draw + — `10/11/2026 12:00` beside a calendar glyph — so any number written here is a guess + about something the user agent already knows, and it was guessed wrong twice: 18ch + clipped the year off a text box, and 20ch clipped the minutes behind the icon on the + picker. Sizing the control to its own content is right in every locale and at every + font size, and it cannot go stale the way a measured constant does. + + `flex: none` because the surrounding `.range-part` sets `min-width: 0` so its label + can shrink, and without this the toolbar could squeeze the control instead — which is + the same clipping by another route. */ + flex: none; font-variant-numeric: tabular-nums; } + .range-quick { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .range-quick-btn { + font-size: 12px; + padding: 4px 8px; + } + .range-sep { font-size: 12px; color: var(--mcode-text-subtle); @@ -1895,18 +1921,29 @@

会话轨迹

时间区间 + +
+ + + + +
-

留空 = 不限。填写会话开始后的时间,格式 1:30 或 1:30:00。

+

留空 = 不限。选一个时间区间,或用快捷按钮。

@@ -2194,124 +2231,46 @@

检视

} /* ---------------- time range ---------------- - Milliseconds since the session started. Offsets are the default because they are the - one clock the ledger already prints on every row, so a number a reader is looking at can - be typed straight in — but a session that runs past midnight outlives that. This one - crosses 2026-10-11 at offset 12:14:29, and there `12:00:00` is last night while - `13:00:00` is a quarter of an hour later: an hour of offset, fifteen minutes of wall - clock, and midnight inside it with nothing on the page saying so. So a box may also - hold a wall clock, and `hasRangeDate` decides which from the text alone. - - Three outcomes, kept apart on purpose: - null → empty, that end is open (or the whole filter is off) - NaN → the text is not a time; the filter keeps its last readable value - integer → the offset in ms, negative when the bound precedes session start - - `1:30` and `1:30:00` are both read as minutes and minutes:seconds, matching the - `893 分 21 秒` the row shows. Bare digits are minutes, because "the first ten - minutes" is what a reader types, and the hint always prints the resolved value back - so a misreading is visible rather than silent. */ + Wall-clock bounds, returned as milliseconds since the session started — which is the + only clock the ledger can filter on, because `record.relativeMs` and the overview axis + are already measured from it. - /** - * @param {string} text - * @returns {number|null} ms, `null` when empty, `NaN` when unreadable - */ - function parseRangeOffset(text) { - var raw = String(text === null || text === undefined ? '' : text).trim(); - if (raw === '') return null; - // A bare number is minutes. Anything else must be colon-separated, so `12:34` is - // never read as twelve hundred thirty-four seconds by accident. - var parts = raw.indexOf(':') >= 0 ? raw.split(':') : [raw]; - if (parts.length > 3) return NaN; - var numbers = []; - for (var i = 0; i < parts.length; i += 1) { - var piece = parts[i].trim(); - // `isCount` would accept `1e3` and `12.5`; neither is a time. - if (!/^\d{1,4}$/.test(piece)) return NaN; - var value = Number(piece); - // Every part but the first is a smaller unit, so 90:00 is not a valid 90 minutes. - if (i > 0 && value > 59) return NaN; - numbers.push(value); - } - // The unit of each part is fixed by its position, so the total is built from the - // seconds rather than by folding the parts and scaling once: folding - // `1:30:00` gives 5400 — hours, not minutes — and scaling that as minutes is how a - // three-part offset came out sixty times too large. - var seconds = numbers.length === 1 - ? numbers[0] * 60 - : numbers.length === 2 - ? numbers[0] * 60 + numbers[1] - : numbers[0] * 3600 + numbers[1] * 60 + numbers[2]; - return seconds * 1000; - } + The controls are `datetime-local`, so what arrives here is already `YYYY-MM-DDTHH:MM` + and the browser has validated it before any of this runs. Three outcomes, kept apart + on purpose: + null → empty, that end is open (or the whole filter is off) + NaN → the text is not a time; the filter keeps its last readable value + integer → the offset in ms, negative when the bound precedes the session - /** - * Whether a box is a wall-clock date rather than an offset. - * - * The two grammars cannot be confused because they share no separator: an offset is - * digits and colons (`1:30`, `1:30:00`), a date is digits and `-` or `/`. So this is a - * property of what the reader typed, decided before any value is computed — the filter, - * the label and the hint all go through it and cannot come out disagreeing. - * - * @param {string} text - * @returns {boolean} - */ - function hasRangeDate(text) { - var raw = String(text === null || text === undefined ? '' : text).trim(); - if (raw === '') return false; - // The day/time part is optional, so `2026-10-10` is a date and reads as that midnight. - return /^\d{4}[-/]\d{1,2}[-/]\d{1,2}([T ]+\d{1,2}(:\d{1,2}(:\d{1,2})?)?)?$/.test(raw); - } + A negative bound is kept rather than clamped. A reader who picks yesterday on a + session that began this morning should be told the session did not exist then, not + handed a range that quietly starts at zero and looks like an answer. */ /** - * A wall-clock bound, returned as an offset from session start. - * - * Subtracting is the whole conversion: every record already carries both `timestamp` and - * `relativeMs`, and `session.startedAt` is the same origin the offsets were measured - * from, so no new clock is introduced anywhere. - * - * The result may be negative, and deliberately so. A session that began at 23:45 today - * has no records at 20:00, and a bound there means exactly that — every record is past - * it. Clamping to zero would also work and would be wrong to notice, but printing - * `-2:45:00` next to the range is what makes it visible instead. - * - * The year is required rather than inferred. Guessing turns `01-01` into a coin flip in - * a session that starts in December, and a range that silently lands on the wrong year - * is worse than one the reader has to finish typing. - * - * @param {string} text + * @param {string} text a `datetime-local` value, `YYYY-MM-DDTHH:MM` or `…:SS` * @param {number|null} sessionStartMs - * @returns {number|null} offset ms, `null` when empty, `NaN` when the text is not a date - * or the session has no start to measure from + * @returns {number|null} offset ms, `null` when empty, `NaN` when it is not a time */ - function parseRangeAbsolute(text, sessionStartMs) { + function parseRangeBound(text, sessionStartMs) { var raw = String(text === null || text === undefined ? '' : text).trim(); if (raw === '') return null; - if (!hasRangeDate(raw)) return NaN; if (!isCount(sessionStartMs)) return NaN; - var parts = raw.split(/[T ]+/); - var dateParts = parts[0].split(/[-\/]/); + var parts = raw.split('T'); + if (parts.length !== 2) return NaN; + var dateParts = parts[0].split('-'); + var clock = parts[1].split(':'); + if (dateParts.length !== 3) return NaN; + if (clock.length !== 2 && clock.length !== 3) return NaN; var year = Number(dateParts[0]); var month = Number(dateParts[1]); var day = Number(dateParts[2]); - var hour = 0; - var minute = 0; - var second = 0; - if (parts.length > 1) { - var clock = parts[1].split(':'); - if (clock.length === 2) { - hour = Number(clock[0]); - minute = Number(clock[1]); - } else if (clock.length === 3) { - hour = Number(clock[0]); - minute = Number(clock[1]); - second = Number(clock[2]); - } else return NaN; - } - // Built from parts, never parsed from the string: `new Date('2026-10-10')` is UTC and - // would silently shift the bound by the offset between here and Greenwich. The - // round-trip below is what rejects 2026-02-30, which Date otherwise rolls forward - // into March and hands back a range the reader never asked for. + var hour = Number(clock[0]); + var minute = Number(clock[1]); + var second = clock.length === 3 ? Number(clock[2]) : 0; + // Built from parts, never handed to `Date` as a string: `new Date('2026-10-10')` is UTC + // and would shift the bound by this machine's distance from Greenwich. The round trip is + // what rejects 2026-02-30, which Date otherwise rolls forward into March and hands back + // a range nobody asked for. var wall = new Date(year, month - 1, day, hour, minute, second, 0); if (isNaN(wall.getTime())) return NaN; if (wall.getFullYear() !== year || wall.getMonth() !== month - 1 || wall.getDate() !== day @@ -2320,34 +2279,65 @@

检视

} /** - * One box, either grammar. The single place that decides which, so nothing downstream can - * treat a wall clock as an offset or the reverse. - * - * @param {string} text - * @param {number|null} sessionStartMs - * @returns {number|null} offset ms, `null` when empty, `NaN` when unreadable + * A bound as the `datetime-local` control wants it: `YYYY-MM-DDTHH:MM`, local time. + * @param {number} ms + * @returns {string} */ - function parseRangeValue(text, sessionStartMs) { - return hasRangeDate(text) ? parseRangeAbsolute(text, sessionStartMs) : parseRangeOffset(text); + function formatRangeInput(ms) { + var wall = new Date(ms); + if (!isCount(ms) || isNaN(wall.getTime())) return ''; + return wall.getFullYear() + '-' + pad2(wall.getMonth() + 1) + '-' + pad2(wall.getDate()) + + 'T' + pad2(wall.getHours()) + ':' + pad2(wall.getMinutes()); + } + + /** Local midnight at or before `ms`. null when `ms` is not a time. */ + function startOfLocalDay(ms) { + var wall = new Date(ms); + if (isNaN(wall.getTime())) return null; + return new Date(wall.getFullYear(), wall.getMonth(), wall.getDate(), 0, 0, 0, 0).getTime(); + } + + /** Local midnight of the day before `ms`. */ + function previousLocalMidnight(ms) { + var wall = new Date(ms); + if (isNaN(wall.getTime())) return null; + return new Date(wall.getFullYear(), wall.getMonth(), wall.getDate() - 1, 0, 0, 0, 0).getTime(); } /** - * @param {number} ms - * @returns {string} `1:30:00`, or `1:30` while it fits in minutes; `-` before a bound that - * lands before the session began + * Where a quick pick lands, as offsets from session start. `now` is a parameter so the + * result is checkable; the page passes `Date.now()`. + * + * Days are stepped through the `Date` constructor rather than by subtracting 86,400,000, + * which lands on the wrong day across a daylight-saving boundary. + * + * @param {string} kind one of the `data-range-quick` values + * @param {number|null} sessionStartMs + * @param {number} now + * @returns {{from: number, to: number}|null} null when the session has no start to measure */ - function formatRangeOffset(ms) { - if (!isCount(ms)) return DASH; - // An absolute bound can land before session start, and `Math.abs` alone would print - // `-2:45:00` as `2:45:00` — the same string a reader would get for a bound two hours - // and forty-five minutes *in*, in the one place the sign is the whole message. - var sign = ms < 0 ? '-' : ''; - var total = Math.floor(Math.abs(ms) / 1000); - var hours = Math.floor(total / 3600); - var minutes = Math.floor((total % 3600) / 60); - var seconds = total % 60; - if (hours > 0) return sign + hours + ':' + pad2(minutes) + ':' + pad2(seconds); - return sign + minutes + ':' + pad2(seconds); + function quickRange(kind, sessionStartMs, now) { + if (!isCount(sessionStartMs) || !isCount(now)) return null; + var todayStart = startOfLocalDay(now); + if (todayStart === null) return null; + var from; + var to = now - sessionStartMs; + if (kind === 'today') { + from = todayStart - sessionStartMs; + } else if (kind === 'yesterday') { + var yesterdayStart = previousLocalMidnight(now); + if (yesterdayStart === null) return null; + from = yesterdayStart - sessionStartMs; + to = todayStart - sessionStartMs; + } else { + var minutes = kind === '15m' ? 15 : kind === '1h' ? 60 : NaN; + if (isNaN(minutes)) return null; + from = now - minutes * 60000 - sessionStartMs; + } + // "The last fifteen minutes" of a session five minutes old is its whole life, not a + // negative offset: the window begins where the session does, and the reader still gets + // everything that exists inside it. + return { from: Math.max(from, 0), to: to }; } /** @@ -2374,30 +2364,21 @@

检视

} /** - * `0:30 – 1:00`, `开始 – 1:00`, `0:30 – 现在`. Empty when neither end is set. + * `2026-10-10 23:50:00 – 2026-10-11 00:20:00`, with either side open or closed. Empty when + * neither end is set. * - * Each end is printed back in the form the reader typed it in: a box holding a date comes - * back as a wall clock, a box holding an offset stays an offset. Printing an offset for a - * date box would be the worst of both — `12:00` beside `2026-10-11 00:45` is a mismatch - * the reader has to decode before they can trust it — and printing a wall clock for an - * offset box would hide the fact that the box is measured from session start, not from - * midnight. + * Wall clocks on both sides, because naming a moment is the whole point of the control. + * Printing an offset here would hand back the one number the reader cannot place. * - * @param {string} fromText @param {string} toText what each box holds, verbatim * @param {number|null} from @param {number|null} to offsets in force * @param {number|null} sessionStartMs */ - function rangeLabel(fromText, toText, from, to, sessionStartMs) { + function rangeLabel(from, to, sessionStartMs) { if (!isCount(from) && !isCount(to)) return ''; - return (isCount(from) ? rangeEndLabel(fromText, from, sessionStartMs) : '开始') + if (!isCount(sessionStartMs)) return ''; + return (isCount(from) ? formatClock(from + sessionStartMs) : '开始') + ' – ' - + (isCount(to) ? rangeEndLabel(toText, to, sessionStartMs) : '现在'); - } - - /** One end of the range label, in whichever clock its own box was written in. */ - function rangeEndLabel(text, ms, sessionStartMs) { - if (hasRangeDate(text) && isCount(sessionStartMs)) return formatClock(ms + sessionStartMs); - return formatRangeOffset(ms); + + (isCount(to) ? formatClock(to + sessionStartMs) : '现在'); } /** Absolute local wall-clock time. */ @@ -2659,6 +2640,16 @@

检视

return top; } + /** The latest wall clock any record carries — the upper bound for the pickers. */ + function latestRecordTimestamp() { + var top = null; + for (var i = 0; i < state.flat.length; i += 1) { + var at = state.flat[i] && state.flat[i].record ? state.flat[i].record.timestamp : null; + if (isCount(at) && (top === null || at > top)) top = at; + } + return top; + } + /** Records written since the range was set — the ones 更新到最新 would bring in. */ function recordsPastCutoff() { if (!isCount(state.rangeCutoff)) return 0; @@ -2678,8 +2669,7 @@

检视

} function timeRangeLabel() { - return rangeLabel(state.rangeFromText, state.rangeToText, state.rangeFrom, state.rangeTo, - sessionStartedAt()); + return rangeLabel(state.rangeFrom, state.rangeTo, sessionStartedAt()); } /* ---------------- DOM helpers ---------------- */ @@ -2872,6 +2862,7 @@

检视

dom.rangeTo = document.getElementById('range-to'); dom.rangeClear = document.getElementById('range-clear'); dom.rangeRefresh = document.getElementById('range-refresh'); + dom.rangeQuick = document.querySelector('.range-quick'); dom.rangeHint = document.getElementById('range-hint'); dom.windowBar = document.getElementById('window-bar'); dom.headerToggle = document.getElementById('header-toggle'); @@ -4923,44 +4914,43 @@

检视

} /** - * What the two boxes currently mean. + * What the two pickers currently mean. * - * Three states, and the reader has to be able to tell them apart without guessing: no - * range, a range in force, and a half-typed value the page could not read. The last one - * keeps the previous range rather than blanking it — silently handing back the whole - * session mid-edit is the worst of the three — and says so here. + * The reader has to be able to tell these apart without guessing: no range, a range in + * force, a range that catches nothing, a value the picker could not read, and a session + * with no clock to filter on. An unreadable value keeps the previous range rather than + * blanking it — silently handing back the whole session mid-edit is the worst of them. */ function updateRangeHint() { if (!dom.rangeHint) return; // Read the *boxes*, not the filter. An unreadable value is deliberately never written - // into state — that is what stops it switching the range off mid-keystroke — so + // into state — that is what stops it switching the range off mid-edit — so // testing `state.rangeTo` for NaN here could only ever be false, and the message the // reader most needs became unreachable. Typing `abc` reported 「起点晚于终点」, // which is true of the *previous* range and says nothing about what they just typed. var base = sessionStartedAt(); var fromUnreadable = state.rangeFromText.trim() !== '' - && isNaN(parseRangeValue(state.rangeFromText, base)); + && isNaN(parseRangeBound(state.rangeFromText, base)); var toUnreadable = state.rangeToText.trim() !== '' - && isNaN(parseRangeValue(state.rangeToText, base)); - // A perfectly good date that this session cannot measure from. Folding it into the - // "unrecognised format" branch would send the reader hunting for a typo that is not - // there, and worse, it is the only way a date silently fails — silently is the part that - // matters, since everything else about the page would look like it had been filtered. - var dateWithoutBase = base === null - && (hasRangeDate(state.rangeFromText) || hasRangeDate(state.rangeToText)); + && isNaN(parseRangeBound(state.rangeToText, base)); + // A bound the session cannot be measured against. Folding this into the "unreadable" + // branch would send the reader hunting for a typo that is not there, and worse, it is + // the one way a range fails silently — everything else would look as though it filtered. + var noBase = base === null + && (state.rangeFromText.trim() !== '' || state.rangeToText.trim() !== ''); var reversed = isCount(state.rangeFrom) && isCount(state.rangeTo) && state.rangeFrom > state.rangeTo; dom.rangeHint.classList.remove('is-invalid'); - if (dateWithoutBase) { + if (noBase) { dom.rangeHint.classList.add('is-invalid'); - dom.rangeHint.textContent = '这个会话没有记录带时间戳,无法按日期筛选;偏移 1:30 仍然可用。'; + dom.rangeHint.textContent = '这个会话的记录没有时间戳,无法按时间筛选。'; return; } if (fromUnreadable || toUnreadable) { dom.rangeHint.classList.add('is-invalid'); - dom.rangeHint.textContent = '格式无法识别,仍按上一次的有效区间统计' + dom.rangeHint.textContent = '这一刻无法识别,仍按上一次的有效区间统计' + (hasTimeRange() ? '(' + timeRangeLabel() + ')' : '') - + '。偏移写 1:30 或 1:30:00,日期写 2026-10-10 23:30。'; + + '。'; return; } if (reversed) { @@ -4970,8 +4960,16 @@

检视

} if (!hasTimeRange()) { dom.rangeHint.classList.remove('is-invalid'); - dom.rangeHint.textContent = '留空 = 不限。填会话开始后的时间 1:30 / 1:30:00,' - + '或日期时间 2026-10-10 23:30(跨零点时用这个)。'; + dom.rangeHint.textContent = '留空 = 不限。选一个时间区间,或用快捷按钮。'; + return; + } + // A well-formed window that lies outside the session. This is the answer a reader who + // pressed 昨天 on a session that began this morning is owed, and it has to name where + // the session actually starts — otherwise an empty table reads as 「这个会话没有内容」. + if (!rangeHasRecords()) { + dom.rangeHint.classList.add('is-invalid'); + dom.rangeHint.textContent = '这段时间里没有记录。这个会话从 ' + + formatClock(base) + ' 开始。'; return; } var stats = kpiStats(); @@ -5023,24 +5021,23 @@

检视

} /** - * Reads both boxes into the filter. + * Reads both pickers into the filter. * * A box the page could not read is left alone rather than assigned: `NaN` fails the * `isCount` test behind `hasTimeRange`, so writing it into state would switch the whole - * range off — handing back the entire session in the middle of typing `1:2`. The hint - * says which value is live. + * range off and hand back the entire session. The hint says which value is live. */ function applyTimeRange() { - // One origin for both boxes, read once: two boxes resolved against two different starts + // One origin for both pickers, read once: two ends resolved against two different starts // would be a range whose width depends on which side was parsed last. var base = sessionStartedAt(); - var from = parseRangeValue(state.rangeFromText, base); - var to = parseRangeValue(state.rangeToText, base); + var from = parseRangeBound(state.rangeFromText, base); + var to = parseRangeBound(state.rangeToText, base); if (!isNaN(from)) state.rangeFrom = from; if (!isNaN(to)) state.rangeTo = to; if (hasTimeRange()) { - // Re-cut the snapshot on every apply, not only on the transition. Editing either box + // Re-cut the snapshot on every apply, not only on the transition. Changing either end // is the reader saying what they want to see now, so it must also pick up what has // arrived since; a range they did not touch stays where it was. state.rangeCutoff = latestRecordIndex(); @@ -5052,9 +5049,9 @@

检视

// at was chosen for a set of records that no longer exists. Setting a range used to // force 「全部」 instead, on the theory that a 200-cap inside a range silently drops // the start of what the reader asked for. Measured, that theory is not worth its - // price — a range starting at 20:00 covers the whole session, 全部 then rendered - // ~11,000 rows and took the document to 414,000px, where one query took 19s. The - // window bar still says how many are not loaded and 「显示全部」 is still one click. + // price — a range covering the whole session rendered ~11,000 rows and took the + // document to 414,000px, where one query took 19s. The window bar still says how many + // are not loaded and 「显示全部」 is still one click. // // 不自动追加 does not depend on this: the snapshot cutoff holds the range still on // its own, which is what it was measured doing. @@ -5062,6 +5059,58 @@

检视

render(); } + /** Whether any record at all falls inside the range. */ + function rangeHasRecords() { + if (!hasTimeRange()) return true; + for (var i = 0; i < state.flat.length; i += 1) { + var record = state.flat[i] ? state.flat[i].record : null; + // Early exit on the first hit, so the common case costs one comparison rather than a + // walk of twelve thousand entries on every render. + if (record && inTimeRange(record)) return true; + } + return false; + } + + /** + * Puts a quick pick into both pickers and applies it. + * + * The pickers are written as well as the filter: a reader who presses 今天 should be able + * to see the dates it chose and adjust one of them, rather than being told a range they + * cannot inspect. + */ + function applyQuickRange(kind) { + var base = sessionStartedAt(); + var span = quickRange(kind, base, Date.now()); + if (!span) return; + state.rangeFromText = formatRangeInput(base + span.from); + state.rangeToText = formatRangeInput(base + span.to); + if (dom.rangeFrom) dom.rangeFrom.value = state.rangeFromText; + if (dom.rangeTo) dom.rangeTo.value = state.rangeToText; + applyTimeRange(); + } + + /** + * Bounds the pickers to the span the session actually covers. + * + * Not validation — the page still reads a value outside these and says so — but the + * calendar opens on the days that have records on them, and the spinner will not walk a + * reader into 1970 looking for something this session never had. + */ + function syncRangeBounds() { + var base = sessionStartedAt(); + var top = latestRecordTimestamp(); + var low = base === null ? '' : formatRangeInput(base); + var high = isCount(top) ? formatRangeInput(top) : ''; + if (dom.rangeFrom) { + dom.rangeFrom.min = low; + dom.rangeFrom.max = high; + } + if (dom.rangeTo) { + dom.rangeTo.min = low; + dom.rangeTo.max = high; + } + } + /** The clear and refresh buttons, which exist only while a range does. */ function syncRangeButtons() { var isSet = hasTimeRange(); @@ -5592,6 +5641,10 @@

检视

// changes on every poll — it has to be re-decided here or it would offer to refresh // for records that are already in, or stay hidden while 200 records pile up behind it. syncRangeButtons(); + // The picker bounds move with the session, which grows during the very first poll. + // Setting them once at startup would leave the calendar capped at whatever the + // session held when the page opened. + syncRangeBounds(); renderOverview(); renderLedger(); renderInspector(); @@ -5837,20 +5890,28 @@

检视

event.preventDefault(); }); - // Both boxes feed one filter, so one debounce covers editing either of them: `1:2` - // resolves to nothing and `1:20` resolves to a range, and a reader who pauses mid - // keystroke should not see the table jump through the whole session in between. + // Both pickers feed one filter, so one handler covers either of them. There is no + // debounce: a `datetime-local` fires `change` only once the reader has committed a whole + // value, and committing it twice would make the table jump for nothing. var onRangeInput = function () { + if (rangeTimer !== null) window.clearTimeout(rangeTimer); + rangeTimer = null; state.rangeFromText = dom.rangeFrom.value; state.rangeToText = dom.rangeTo.value; - if (rangeTimer !== null) window.clearTimeout(rangeTimer); - rangeTimer = window.setTimeout(function () { - rangeTimer = null; - applyTimeRange(); - }, SEARCH_DEBOUNCE); + applyTimeRange(); }; - dom.rangeFrom.addEventListener('input', onRangeInput); - dom.rangeTo.addEventListener('input', onRangeInput); + dom.rangeFrom.addEventListener('change', onRangeInput); + dom.rangeTo.addEventListener('change', onRangeInput); + // A quick pick is a committed value like any other, so it goes through the same path: + // one filter, one window reset, one render. + var quickButtons = dom.rangeQuick ? dom.rangeQuick.querySelectorAll('.range-quick-btn') : []; + for (var q = 0; q < quickButtons.length; q += 1) { + quickButtons[q].addEventListener('click', function () { + if (rangeTimer !== null) window.clearTimeout(rangeTimer); + rangeTimer = null; + applyQuickRange(this.getAttribute('data-range-quick')); + }); + } dom.rangeRefresh.addEventListener('click', function () { if (rangeTimer !== null) window.clearTimeout(rangeTimer); rangeTimer = null; From 5da318dca2549934911e2ad0fb2e0e484af0e525 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 12:19:18 +0800 Subject: [PATCH 17/19] =?UTF-8?q?feat(mmc-trajectory):=20add=20an=20?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=20filter=20whose=20rules=20live=20in=20one?= =?UTF-8?q?=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An interruption is the thing a reader of this page most wants to find, and until now nothing pointed at one. 失败 was a pill on a row and 压缩 was a band, so those two were findable by eye; a turn that simply took a prompt and never answered had no marking anywhere, which is the shape an interrupted turn leaves behind. So 异常 is a filter, not a kind. A kind is a label the runtime stamps on a row; 异常 is a question asked across the whole ledger — 「这次运行在哪儿出的问题」 — and its answer lands on three unrelated kinds at once. Filing it as a tenth kind would have put it in the type chips, where it reads as one more category instead of the second axis it actually is. Each rule is one predicate plus the label the reader sees when it fires: - toolError 工具失败 — a tool result the runtime marked as an error - compaction 上下文压缩 — the checkpoint written when the history underneath a turn is rewritten - noReply 无答复 — a turn that took a prompt and never produced a reply Adding a rule is adding an entry to that table. The buttons are built from it rather than written into the markup, so a rule has exactly one name in exactly one place. The kind chips already had this defect once: the label lived in the markup and the accessible name was written beside it, so a rename left a button showing one word and announcing another, and nothing in the guards could see it because both halves were individually correct. The turn still running is excluded from 无答复. It is open because it is going, which is not the same thing as broken, and a rule that re-flagged the newest thing on screen on every 2.5s poll would be a filter nobody could leave on. The counts on the buttons and the rows in the list come from one predicate. anomalyCounts() walks the flat list to fill the buttons, and while that walk was its own copy a button could promise 246 while the list showed 12 — the same 「筛选了但什么都没变」 this page keeps guarding against, moved up one level. Both go through passesBaseFilter() now. The overview strip is handed a bare record and 无答复 is a property of the turn, so the owning turn comes back out of a turn index; without that lookup the strip and the ledger directly above it answer different questions about the same row. 无答复 is marked on the turn head and not on each row. It is one thing that happened to a whole turn, and turn 82 alone would have carried the tag 352 times — at which point it stops being a signal and becomes wallpaper. Measured on the live session: 94 turns, 13,132 records, 1,416 anomalous — 251 tool failures, 15 checkpoints, and 1,184 records spread across 10 turns that never answered. Probes: 98 assertions in verify-anomaly.mjs against the running session, with the expected counts taken straight off the payload by code that shares nothing with the page, and 27 mutations each caught by the guard that owns the defect. Two guards in verify-time-range.mjs that pinned the old shape of visibleEntries() and clearFilters() were rewritten to pin the invariants instead of the layout, and the reset-call inventory moved from ten to eleven for the new filter. --- .../mmc-trajectory/miniapp/client/index.html | 355 +++++++++++++++++- 1 file changed, 349 insertions(+), 6 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 220a065..5145aaa 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -475,6 +475,35 @@ padding: 4px 8px; } + /* 异常 sits beside 快捷区间 in the same toolbar, so the two groups are sized alike and + read as one filter bar. What sets them apart is the count: it is the reason the group + exists, because a filter that cannot say how much it would keep is one the reader has + to run blind. */ + .anomaly-quick { + display: inline-flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + } + + .anomaly-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 4px 8px; + } + + .anomaly-count { + font-variant-numeric: tabular-nums; + color: var(--mcode-text-subtle); + } + + /* Pressed is carried by aria-pressed and the label, never by this colour alone. */ + .anomaly-btn[aria-pressed='true'] .anomaly-count { + color: inherit; + } + .range-sep { font-size: 12px; color: var(--mcode-text-subtle); @@ -1161,6 +1190,14 @@ white-space: nowrap; } + /* A turn-level anomaly tag. Sized with the head it sits in, and carrying its own word + rather than a colour, so 「异常」 is readable as text in the ledger and in a copied + summary of it. */ + .turn-flag { + font-size: 12px; + white-space: nowrap; + } + .turn-meta { display: flex; flex-wrap: wrap; @@ -1945,6 +1982,17 @@

会话轨迹

留空 = 不限。选一个时间区间,或用快捷按钮。

+
+
+ 异常 + +
+
+
+
@@ -2129,6 +2177,111 @@

检视

return (record && KIND_LABELS[record.kind]) || (record && record.kind) || DASH; } + /* ---------------- 异常 ---------------- + + 异常 is not a record kind, and it is deliberately not one. A kind is a label the + runtime stamps on a row; 异常 is a question asked across the whole ledger — 「这次运行 + 在哪儿出的问题」 — and its answer lands on three unrelated kinds at once. Filing it as + a tenth kind would have put it in the type chips, where it reads as one more category + instead of the second axis it actually is. + + So it is a rule set. A rule is one predicate plus the label the reader sees when it + fires; the filter is 「one of the ticked rules fired on this record」. Adding a rule is + adding an entry to this table and nothing else — the buttons, their counts and the + turn-head tag all read it. + + Two of the three rules are already visible in the rows themselves (失败 is a pill, a + checkpoint is a band). They are here anyway, because the filter has to be able to + name what it kept: a filter that hides rows without saying why is the one thing this + page refuses to ship. */ + var ANOMALY_RULES = [ + { + id: 'toolError', + label: '工具失败', + // The runtime's own verdict on a tool result, carrying the message it failed with. + record: function (record) { + return !!record && record.isError === true; + } + }, + { + id: 'compaction', + label: '上下文压缩', + // The checkpoint written when the runtime rewrites the conversation underneath the + // turn. A turn that got cut off and picked up again leaves one of these behind. + record: function (record) { + return !!record && record.kind === 'compaction'; + } + }, + { + id: 'noReply', + label: '无答复', + // A turn that took a prompt and never produced a reply — the shape an interruption + // leaves behind. The turn still running is excluded: it is open because it is + // going, which is not the same thing as broken, and a filter that re-flags the live + // turn on every 2.5s poll is a filter nobody can leave on. + turn: function (turn, ctx) { + return !!turn && turn.closed === false && turn !== ctx.liveTurn; + } + } + ]; + + var ANOMALY_RULE_IDS = ANOMALY_RULES.map(function (rule) { + return rule.id; + }); + + function anomalyRuleLabel(id) { + for (var i = 0; i < ANOMALY_RULES.length; i += 1) { + if (ANOMALY_RULES[i].id === id) return ANOMALY_RULES[i].label; + } + return id || DASH; + } + + /** + * One rule, one answer. A rule is scoped to a record or to the turn that record sits in, + * and only ever one of the two — so the two kinds of rule cannot both be asked at once + * and cannot be half-applied. + */ + function anomalyRuleFires(rule, record, ctx) { + if (rule.record) return !!rule.record(record, ctx); + return !!(rule.turn && ctx && ctx.turn && rule.turn(ctx.turn, ctx)); + } + + /** + * Every rule that fires on this record, whether or not the reader has ticked it. + * + * The button counts need all of them, not just the ticked ones: a button that could + * only count what is already on would read 0 the moment you looked at it. + * + * @param {any} record + * @param {{ turn: any, liveTurn: any }} ctx + * @returns {string[]} + */ + function recordAnomalyRules(record, ctx) { + var out = []; + for (var i = 0; i < ANOMALY_RULES.length; i += 1) { + if (anomalyRuleFires(ANOMALY_RULES[i], record, ctx)) out.push(ANOMALY_RULES[i].id); + } + return out; + } + + /** + * The 异常 half of the filter. An empty `enabled` is the filter being OFF, not a rule + * that matches nothing — the difference matters, because off has to let every record + * through instead of quietly emptying the ledger. + * + * @param {any} record + * @param {{ turn: any, liveTurn: any }} ctx + * @param {string[]} enabled + */ + function passesAnomaly(record, ctx, enabled) { + if (!enabled || !enabled.length) return true; + var hits = recordAnomalyRules(record, ctx); + for (var i = 0; i < hits.length; i += 1) { + if (enabled.indexOf(hits[i]) >= 0) return true; + } + return false; + } + function isNil(value) { return value === null || value === undefined || value === ''; } @@ -2786,6 +2939,11 @@

检视

// more thing that has to be truthful about what it covers, and a filter has nothing // to explain. kinds: KIND_ORDER.slice(), + // Which 异常 rules are ticked. Empty means the filter is off — not 「nothing matches」, + // which is the distinction that keeps a turned-off filter from blanking the ledger. + // Kept as a list of rule ids rather than a boolean so a later rule is one more entry + // here and one more button, not a second piece of state that has to agree with this. + anomalyRules: [], query: '', // Time range, in milliseconds since the session started — the same zero the ledger's // `+893 分 21 秒` column counts from. `null` on either side means that end is open, @@ -2864,6 +3022,7 @@

检视

dom.rangeRefresh = document.getElementById('range-refresh'); dom.rangeQuick = document.querySelector('.range-quick'); dom.rangeHint = document.getElementById('range-hint'); + dom.anomalyControls = document.getElementById('anomaly-controls'); dom.windowBar = document.getElementById('window-bar'); dom.headerToggle = document.getElementById('header-toggle'); dom.kpiToggle = document.getElementById('kpi-toggle'); @@ -3038,6 +3197,14 @@

检视

state.hardError = null; state.stale = false; state.flat = flattenTurns(payload.turns); + // The overview reaches the filter holding a bare record, but 无答复 is a property of + // the turn the record sits in, so the owning turn has to be reachable from the + // record alone. Built here rather than searched per call: this runs on every poll. + turnById = new Map(); + var payloadTurns = Array.isArray(payload.turns) ? payload.turns : []; + for (var t = 0; t < payloadTurns.length; t += 1) { + if (payloadTurns[t] && payloadTurns[t].id) turnById.set(payloadTurns[t].id, payloadTurns[t]); + } if (changed) { rePinWindow(); @@ -3676,6 +3843,10 @@

检视

// here, and a range cannot claim to contain something it never saw. var overviewSpansById = new Map(); + // record id -> the turn that owns it, rebuilt on every payload. Exists so a filter that + // is handed a bare record can still ask the question only the turn can answer. + var turnById = new Map(); + function recordInSelection(record, selection, spansById) { if (!selection) return true; var span = spansById.get(record.id); @@ -4188,17 +4359,82 @@

检视

/* ---------------- rendering: ledger ---------------- */ + /** + * The turn the session is still writing into — the last one the payload carries. It is + * only ever consulted while that turn is unclosed, which is exactly when 无答复 would + * otherwise fire on the turn the reader is watching happen. + */ + function liveTurn() { + var turns = state.payload && state.payload.turns; + return Array.isArray(turns) && turns.length ? turns[turns.length - 1] : null; + } + + /** + * Everything except 异常, in one place. + * + * The ledger and the 异常 rule counts both go through here. They each had their own + * copy for a while, and the result was a button promising 246 rows next to a list + * showing 12 — the same 「筛选了但什么都没变」 this file keeps guarding against, moved + * up one level. + */ + function passesBaseFilter(record, selection) { + if (!hasKind(record, state.kinds)) return false; + if (state.query && !matchesQuery(record, state.query)) return false; + // The overview brush is a further filter, applied after kind and text so that + // clearing it never silently changes what the other two meant. + if (selection && !recordInSelection(record, selection, overviewSpansById)) return false; + if (!inTimeRange(record)) return false; + return true; + } + + function anomalyContext(entry) { + return { turn: entry.turn, liveTurn: liveTurn() }; + } + + /** + * How many records each rule would keep, given every other filter exactly as it stands. + * Counted through passesBaseFilter so a button can never advertise a number the ledger + * will not deliver. `any` counts records, not rule hits: one row can trip two rules, + * and 「全部异常」 promising more rows than exist is the same lie as promising fewer. + */ + function anomalyCounts() { + var byRule = {}; + for (var i = 0; i < ANOMALY_RULES.length; i += 1) byRule[ANOMALY_RULES[i].id] = 0; + var any = 0; + var selection = state.overviewSelection; + var live = liveTurn(); + for (var j = 0; j < state.flat.length; j += 1) { + var entry = state.flat[j]; + if (!passesBaseFilter(entry.record, selection)) continue; + var hits = recordAnomalyRules(entry.record, { turn: entry.turn, liveTurn: live }); + if (!hits.length) continue; + any += 1; + for (var k = 0; k < hits.length; k += 1) byRule[hits[k]] += 1; + } + return { byRule: byRule, any: any }; + } + + /** + * The rules that fired on a whole turn. Read straight off the table rather than through + * a record, because a broken turn is usually made of rows that each look fine. + */ + function turnAnomalyRules(turn) { + var ctx = { turn: turn, liveTurn: liveTurn() }; + var out = []; + for (var i = 0; i < ANOMALY_RULES.length; i += 1) { + var rule = ANOMALY_RULES[i]; + if (rule.turn && rule.turn(turn, ctx)) out.push(rule.id); + } + return out; + } + function visibleEntries() { var out = []; var selection = state.overviewSelection; for (var i = 0; i < state.flat.length; i += 1) { var entry = state.flat[i]; - if (!hasKind(entry.record, state.kinds)) continue; - if (state.query && !matchesQuery(entry.record, state.query)) continue; - // The overview brush is a further filter, applied after kind and text so that - // clearing it never silently changes what the other two meant. - if (selection && !recordInSelection(entry.record, selection, overviewSpansById)) continue; - if (!inTimeRange(entry.record)) continue; + if (!passesBaseFilter(entry.record, selection)) continue; + if (!passesAnomaly(entry.record, anomalyContext(entry), state.anomalyRules)) continue; out.push(entry); } return out; @@ -4401,6 +4637,7 @@

检视

return state.query !== '' || state.kinds.length !== KIND_ORDER.length || state.overviewSelection !== null || hasTimeRange() + || state.anomalyRules.length !== 0 ; } @@ -4418,6 +4655,11 @@

检视

// records the list refused to render — the same "filtered but nothing changed" this // function exists to prevent. if (!inTimeRange(record)) return false; + // 异常 belongs here for the same reason. The strip is handed a bare record, so the + // owning turn comes back out of turnById instead of off the flat entry the ledger + // gets — the same question, the same table, one lookup apart. + var owner = record ? turnById.get(record.turnId) : null; + if (!passesAnomaly(record, { turn: owner || null, liveTurn: liveTurn() }, state.anomalyRules)) return false; return true; } @@ -4429,6 +4671,15 @@

检视

var title = '第 ' + (isCount(turn.index) ? turn.index : DASH) + ' 轮'; head.appendChild(el('span', 'turn-title', title)); + // A turn that never answered is a fact about the session, not about the filter in + // force, so it is marked whether or not 异常 is ticked. It goes on the head and not on + // each row because it is one thing that happened to the whole turn — stamped across + // 352 rows it would stop being a signal and become wallpaper. + var turnFlags = turnAnomalyRules(turn); + for (var flag = 0; flag < turnFlags.length; flag += 1) { + head.appendChild(el('span', 'turn-flag note note-danger', anomalyRuleLabel(turnFlags[flag]))); + } + // Under a filter, describe the visible rows; otherwise trust the server's // whole-turn numbers, which are exact and cost nothing to read. var filtered = isFiltering(); @@ -4998,10 +5249,12 @@

检视

function clearFilters() { state.kinds = KIND_ORDER.slice(); state.query = ''; + state.anomalyRules = []; clearTimeRange(); resetWindowMode(); if (dom.searchInput) dom.searchInput.value = ''; syncKindButtons(); + syncAnomalyButtons(); render(); } @@ -5645,6 +5898,9 @@

检视

// Setting them once at startup would leave the calendar capped at whatever the // session held when the page opened. syncRangeBounds(); + // The 异常 buttons carry a count that has to follow the session and every other filter, + // exactly like the refresh button's decision to appear. + syncAnomalyButtons(); renderOverview(); renderLedger(); renderInspector(); @@ -5771,6 +6027,79 @@

检视

announce(enabled ? '已开启跟随最新,正在跟踪最近的会话。' : '已关闭跟随最新,固定在所选会话。'); } + /** + * Builds one button per rule, plus 全部异常 for the set as a whole. Built from the table + * rather than written into the markup so that a rule has exactly one name, in one place. + */ + function buildAnomalyButtons() { + if (!dom.anomalyControls) return; + dom.anomalyControls.textContent = ''; + for (var i = 0; i < ANOMALY_RULES.length; i += 1) { + var rule = ANOMALY_RULES[i]; + var button = el('button', 'btn btn-quiet anomaly-btn'); + button.type = 'button'; + button.id = 'anomaly-' + rule.id; + button.setAttribute('data-anomaly-rule', rule.id); + button.setAttribute('aria-pressed', 'false'); + button.appendChild(el('span', 'anomaly-label', rule.label)); + button.appendChild(el('span', 'anomaly-count', '0')); + dom.anomalyControls.appendChild(button); + } + var all = el('button', 'btn btn-quiet anomaly-btn'); + all.type = 'button'; + all.id = 'anomaly-all'; + all.setAttribute('data-anomaly-all', ''); + all.setAttribute('aria-pressed', 'false'); + all.appendChild(el('span', 'anomaly-label', '全部异常')); + all.appendChild(el('span', 'anomaly-count', '0')); + dom.anomalyControls.insertBefore(all, dom.anomalyControls.firstChild); + } + + /** + * Counts and pressed state for the 异常 buttons. Both are read from the same pass the + * ledger filters through, so a number on a button and the rows below it are the same + * statement about the same records. + */ + function syncAnomalyButtons() { + if (!dom.anomalyControls) return; + var counts = anomalyCounts(); + var buttons = dom.anomalyControls.querySelectorAll('.anomaly-btn'); + for (var i = 0; i < buttons.length; i += 1) { + var button = buttons[i]; + var isAll = button.hasAttribute('data-anomaly-all'); + var id = button.getAttribute('data-anomaly-rule'); + var n = isAll ? counts.any : (counts.byRule[id] || 0); + var on = isAll + ? state.anomalyRules.length === ANOMALY_RULES.length + : state.anomalyRules.indexOf(id) >= 0; + var noun = isAll ? '全部异常' : anomalyRuleLabel(id); + var label = noun + ' ' + formatInt(n) + ' 条 · ' + (on ? '当前只看这些 · 点击取消' : '当前未启用 · 点击只看这些'); + button.querySelector('.anomaly-count').textContent = formatInt(n); + button.setAttribute('aria-pressed', on ? 'true' : 'false'); + button.setAttribute('aria-label', label); + button.setAttribute('title', label); + button.classList.toggle('is-empty', n === 0); + } + } + + function onAnomalyToggle(ruleId) { + var next; + if (ruleId === null) { + next = state.anomalyRules.length === ANOMALY_RULES.length ? [] : ANOMALY_RULE_IDS.slice(); + } else { + next = state.anomalyRules.filter(function (id) { + return id !== ruleId; + }); + if (next.length === state.anomalyRules.length) next = next.concat([ruleId]); + next.sort(function (a, b) { + return ANOMALY_RULE_IDS.indexOf(a) - ANOMALY_RULE_IDS.indexOf(b); + }); + } + state.anomalyRules = next; + resetWindowMode(); + render(); + } + function syncKindButtons() { var buttons = dom.kindControls.querySelectorAll('.tbtn-action'); for (var i = 0; i < buttons.length; i += 1) { @@ -5925,6 +6254,20 @@

检视

render(); }); + // Built before it is bound, so the buttons and their handlers come from the same + // table and cannot be two lists that drift. + buildAnomalyButtons(); + var anomalyButtons = dom.anomalyControls.querySelectorAll('.anomaly-btn'); + for (var a = 0; a < anomalyButtons.length; a += 1) { + anomalyButtons[a].addEventListener('click', function () { + // Read the pressed state at click time rather than closing over it: the buttons + // are re-synced on every poll, and a captured flag goes stale between the render + // and the click. + var id = this.getAttribute('data-anomaly-rule'); + onAnomalyToggle(id || null); + }); + } + var kindButtons = dom.kindControls.querySelectorAll('.tbtn-action'); for (var j = 0; j < kindButtons.length; j += 1) { if (kindButtons[j].id === 'kind-all') continue; From 2563b37f39f299a78a563d5149f7153a9283ad19 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 12:50:04 +0800 Subject: [PATCH 18/19] feat(mmc-trajectory): an empty reply is a reply, and an anomaly too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracing an interruption back to the raw session file turned up a shape the 异常 filter could not see, and the reason is worth writing down because the rule set was built on the assumption that every anomaly is a *present* signal — a flag, a kind, a missing turn. The turn the reader watched finish without an answer was closed like this: "role": "assistant", "content": [], "stopReason": "stop", "usage": { "output": 78, ... }, "responseId": "071a35d49ed..." The call happened. There is a response id and an output token count. It stopped normally, not aborted, not timed out. It just came back with nothing in it. The turn therefore has a reply record, `closed` is true, and 无答复 — which tests `closed === false` — has nothing to fire on. The 答复 chip counted it the whole time. From the ledger's side it was a reply; from the reader's side nothing was ever displayed. So the new rule tests an absence: record: function (record) { return !!record && record.kind === 'reply' && isNil(record.text); } It keeps its kind on purpose. The record genuinely is a reply, the 答复 chip counts it and the reply filter keeps it — it is an anomaly *as well as* a reply, not instead of one. `text` is the field to read rather than `textTruncated`: a clipped reply still has a body, however short, and whitespace is a body a reader can see. The row needs to say so too, or the filter shows rows that look like ordinary answers. It takes over the duration cell the way 失败 already does, so the five-column grid does not grow, and the name comes from the rule table rather than a second copy written at the call site — the kind chips already paid for having the visible word and the announced word in different places. Measured on the live session: 99 turns, 13,358 records, 1,422 anomalous — 255 tool failures, 15 checkpoints, 1,184 records across 10 turns that never answered, and 2 replies that delivered nothing. Both empty replies sit in closed turns, which is checked against the session rather than assumed, because that overlap is the whole reason this rule exists. Probes: 127 assertions in verify-anomaly.mjs and 37 mutations, each caught by the guard that owns the defect. Two of the new assertions are about what the rule must NOT do — a checkpoint and a bodyless system record are not empty answers, or the union would come out smaller than its parts. --- .../mmc-trajectory/miniapp/client/index.html | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html index 5145aaa..c977097 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/client/index.html +++ b/plugins/avatasia/mmc-trajectory/miniapp/client/index.html @@ -2222,6 +2222,23 @@

检视

turn: function (turn, ctx) { return !!turn && turn.closed === false && turn !== ctx.liveTurn; } + }, + { + id: 'silent', + label: '空答复', + // A reply the runtime recorded and finished normally — `stop`, not an abort — that + // came back with nothing in it. The call really happened: there is a responseId and + // an output token count on it, so this is not a timeout and not a crash. It is the + // shape that leaves a turn looking finished and the reader looking at nothing. + // + // It keeps its kind on purpose. The record genuinely is a reply, the 答复 chip counts + // it and the reply filter keeps it — it is an anomaly *as well as* a reply, not + // instead of one. 无答复 cannot see it either way: an empty reply closes its turn, so + // `closed` is true and that rule has nothing to fire on. Measured on this session: + // two, one of them the turn that ended in front of the reader with nothing shown. + record: function (record) { + return !!record && record.kind === 'reply' && isNil(record.text); + } } ]; @@ -4521,9 +4538,20 @@

检视

// Fixed five-column grid: an error marker takes over the duration cell so // the row never wraps; the exact duration stays available in the inspector. var durationText = isNil(record.durationMs) ? DASH : formatDuration(record.durationMs); + // The same treatment for a reply that arrived empty. It is not an error — the turn + // finished normally — but on the row the only thing separating it from an answer is + // the absence of one, and a row that silently shows nothing is indistinguishable from + // one the page failed to load. + var silent = record.kind === 'reply' && isNil(record.text); if (failed) { row.appendChild(pill('失败', 'error')); row.title = '失败 · 耗时 ' + durationText + '(详情见右侧检视)'; + } else if (silent) { + // The name comes from the rule table, not from a second copy written here. A label + // that exists in two places is the drift this page already paid for once on the kind + // chips: rename the rule and the row keeps announcing the old word. + row.appendChild(pill(anomalyRuleLabel('silent'), 'error')); + row.title = anomalyRuleLabel('silent') + ' · 模型正常结束,但没有返回任何内容'; } else { row.appendChild(el('span', 'row-meta row-col-duration', durationText)); } From 871c5d582dc81389e6e962e9bdc198e7c25a0269 Mon Sep 17 00:00:00 2001 From: avatasia Date: Sun, 11 Oct 2026 13:28:35 +0800 Subject: [PATCH 19/19] docs(mmc-trajectory): state what the code does; harden three read paths The second review round confirmed the five code fixes from the first (segmentOrder, orphan filtering, the 64 MB cap, the runtime route, the SQLite handles) and re-ran them, but the READMEs had been left describing the app as it was before the time range and anomaly filters landed. Bring them back in line, and close the open suggestions while the review is still warm. README, both languages: - "/api/runtime" still advertised environment variable names and an argv scan. Both were removed in the previous round; say what the route returns now and why the scan is gone rather than quietly dropping the sentence. - "The type chips are the only filter" was true when written and is not now. The time range and the anomaly filter cut across kinds rather than over them, so state that instead of leaving the reader with one axis in a UI that has three. - The read was described as bounded by the length history-catalog.json recorded. It is bounded by MAX_MESSAGE_BYTES, and readHead clamps to the last complete line. The cap is sent with the payload and rendered from there, so the README should stop carrying its own copy of the number. - MMC_TRAJECTORY_ROOT replaces the data directory; v2/sessions and v2/sqlite are still resolved beneath it. - Two paragraphs had run together at README.md:62, and the sentence offering "four explicit controls" had lost its verb. server.mjs, three suggestions that were still open: - /api/runtime closed its probe handle inside the try. A schema mismatch is precisely the case this route exists to report, and it is precisely the case where prepare() throws -- so the handle waited on the GC every time. - history_relative_dir was joined onto the sessions root without the containment check resolveArtifactPath gives catalog file names. It is a database column rather than a name this app chose; it gets the same check. - A session with no history-catalog.json -- one that has never compacted -- used to answer artifact_path_rejected and a 500, because artifactByFile was empty even though the active file was real. probe() already synthesised an artifact for exactly this case; reuse that shape instead. npm run check passes (validate clean for this plugin, 55/55 tests) and the existing probes are unchanged: verify-pr15-fixes 26, verify-generations 108, verify-compaction-turn 22, verify-time-range, verify-anomaly 127, verify-overview 108, client-check 168, plus the response-rail, window-widen, call-fold, block-order and prompt-fix probes. --- plugins/avatasia/mmc-trajectory/README.md | 16 ++++--- .../avatasia/mmc-trajectory/README.zh-CN.md | 10 ++-- .../mmc-trajectory/miniapp/node/server.mjs | 48 +++++++++++++++---- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/plugins/avatasia/mmc-trajectory/README.md b/plugins/avatasia/mmc-trajectory/README.md index ef8403d..81a7ce7 100644 --- a/plugins/avatasia/mmc-trajectory/README.md +++ b/plugins/avatasia/mmc-trajectory/README.md @@ -22,7 +22,7 @@ The page follows the most recent conversation by default and re-reads it every 2 The ledger groups records by turn, following the `turn_id` recorded in the session file. Each record is classified as user, answer, narration, thinking, tool call, tool result, system, or compaction, and each turn header carries that turn's own Token counts and elapsed time. Selecting a record opens a detail panel with summary, raw JSON, tool arguments, tool result, and reasoning, plus a button that hands the record to the Agent's chat input. -**The type chips are the only filter.** One thing is being filtered — which record kinds are visible — and it is one array in the client, written from exactly three controls: the eight kind buttons, 全部, and 清除筛选. An earlier version also had a row of layer buttons (turn / thinking / call) sitting above the list as presets over those same chips. They were removed, because any preset a reader outgrows becomes a button that contradicts the chips it was supposed to summarise: untick 问答 while leaving 用户 ticked, and the turn button goes dark and claims the conversation is hidden when in fact it was narrowed. One control that cannot drift is worth more than three convenient ones that can. +**The type chips are the only filter over kinds.** One thing is being filtered — which record kinds are visible — and it is one array in the client, written from exactly three controls: the eight kind buttons, 全部, and 清除筛选. An earlier version also had a row of layer buttons (turn / thinking / call) sitting above the list as presets over those same chips. They were removed, because any preset a reader outgrows becomes a button that contradicts the chips it was supposed to summarise: untick 问答 while leaving 用户 ticked, and the turn button goes dark and claims the conversation is hidden when in fact it was narrowed. One control that cannot drift is worth more than three convenient ones that can. Two further controls cut across kinds instead of over them: a time range, which bounds the ledger to a wall-clock window, and an anomaly filter, whose rules pick out records that are wrong — a failed tool call, a compaction, a turn that never got an answer, an answer that came back empty — rather than records of a particular kind. Neither rewrites the kind array, so every kind chip still answers exactly one question. Naming kinds directly costs one extra click and reaches everything a preset did — tick 用户 *and* 答复 for the conversation, untick both for its absence, and any scattered subset in between, such as only the user's questions or only system records. @@ -59,7 +59,9 @@ disagreeing. Nothing in the file marks it: compared field by field, a 追加 is prompt in role, `stopReason`, api, model, provider, tokens and `producedBy`. The signal available is position — measured across every turn that has a prompt, it sits at record 1 or 2 and never further in — so this is an inference from ordering, not a fact the file asserts, and the detail panel says -so where it shows.One model call produces one row per block, so a rail down the left edge joins the rows that arrived together — a response split into reasoning, answer and tool calls reads as one unit. A response that produced a single row shows a dot instead. The grouping is also stated in each row's tooltip, so it is never conveyed by position alone. Tool results are not model responses, carry no rail, and are not joined to the call that caused them. +so where it shows. + +One model call produces one row per block, so a rail down the left edge joins the rows that arrived together — a response split into reasoning, answer and tool calls reads as one unit. A response that produced a single row shows a dot instead. The grouping is also stated in each row's tooltip, so it is never conveyed by position alone. Tool results are not model responses, carry no rail, and are not joined to the call that caused them. When a session has been compacted, the runtime does not trim it in place: it rotates the previous `messages.jsonl` into `snapshots/`, opens a new generation, and starts a fresh active file. **The ledger still shows the whole conversation.** The app walks the generation chain the same way the runtime does — it opens the active file, reads the generation that file declares, and steps back one generation at a time through the parent each file names — and then concatenates every reachable generation oldest-first. A snapshot whose parent generation is not exactly one lower ends the chain rather than being stitched on, and a snapshot the chain never reaches (a fork leaves those behind) is reported as an orphan instead of being shown. @@ -67,9 +69,9 @@ A **上下文代** (context generation) picker appears once a session has more t ## Data & access -**Files read.** The service resolves the session root in this order: the `MAVIS_HOME` environment variable, then `.minimax` in the home directory, giving `/v2/sessions`. An `MMC_TRAJECTORY_ROOT` environment variable overrides the whole path for testing. From each session directory it reads `messages.jsonl` (the active generation), `manifest.json` (the session id and creation time), and `history-catalog.json` (the generation list, plus a committed byte length per artifact). Each snapshot reachable from the active generation is then read from `snapshots/`, bounded by the length the catalog recorded, and only the first 64 KB of each candidate is needed to decide whether it belongs to the chain at all. It also opens `/v2/sqlite/runtime-state.sqlite` **read-only** to resolve which conversation is active and to read real session titles. +**Files read.** The service resolves the session root in this order: the `MAVIS_HOME` environment variable, then `.minimax` in the home directory, giving `/v2/sessions`. Setting `MMC_TRAJECTORY_ROOT` replaces the data directory itself, for testing; `v2/sessions` and `v2/sqlite` are still resolved beneath it. From each session directory it reads `messages.jsonl` (the active generation), `manifest.json` (the session id and creation time), and `history-catalog.json` (the generation list, plus a committed byte length per artifact). Each snapshot reachable from the active generation is then read from `snapshots/`, bounded by the app's own 64 MB cap rather than by the length the catalog recorded, and only the first 64 KB of each candidate is needed to decide whether it belongs to the chain at all. A session that has never compacted has no `history-catalog.json` at all, and is read from its active file alone. It also opens `/v2/sqlite/runtime-state.sqlite` **read-only** to resolve which conversation is active and to read real session titles. -The catalog length bounds the read so a file that is being appended to is never parsed mid-line; any unparsable line is skipped and counted. The session directory name is `base64url(sessionId)`, so a session id can be recovered from the filename alone; the service cross-checks that against `manifest.json` and the database before reading any file. +Reads are bounded by `MAX_MESSAGE_BYTES` and `readHead` clamps each one to the last complete line, so a file that is being appended to is never parsed mid-line; any unparsable line is skipped and counted. The cap in force is sent to the page with the payload and rendered from there, rather than written as a number in two places that then drift apart. The session directory name is `base64url(sessionId)`, so a session id can be recovered from the filename alone; the service cross-checks that against `manifest.json` and the database before reading any file. Snapshot file names come out of `history-catalog.json`, which is data rather than code, so a name is only used when it is a plain basename with no path separator and no `..`, and the resolved path is then required to still sit inside the session directory. Both checks have to pass before anything is opened. @@ -91,9 +93,9 @@ Three details of the session format are worth knowing, because they change what ## Diagnostics -The Node runtime also serves `GET /api/runtime`, a diagnostic route reporting what the process can observe about its own session identity: the Node version, the count and names of environment variables, whether any environment value or argv entry has the shape of a session id, and whether the runtime database could be opened and what it resolved to. It returns names and structure only — filesystem locations are reduced to a bare filename, and no absolute path appears in the response. +The Node runtime also serves `GET /api/runtime`, a diagnostic route reporting what the process can observe about its own session identity: the Node version, whether the runtime database could be opened and which conversation it resolved to, and the basenames of the working and data directories. It returns names and structure only — every filesystem location is reduced to a bare filename, and no absolute path, process id or command-line argument appears in the response. An earlier version also returned the names of the environment variables and scanned them for anything shaped like a session id; those fields are gone, because handing the host process's environment to a page is a liability and the scan answered nothing this route still needs. -The Host assigns the listening port at startup and logs it as `miniapp.runtime.listening`. This route is the reason the claims in the next section were established rather than assumed, and it is the first thing to check when a build of MiniMax Code changes how the runtime is spawned. On the verified build it reports 13 environment variable names, with no environment value and no argv entry matching a session id. +The Host assigns the listening port at startup and logs it as `miniapp.runtime.listening`. This route is the reason the claims in the next section were established rather than assumed, and it is the first thing to check when a build of MiniMax Code changes how the runtime is spawned. ## How the active session is chosen @@ -110,7 +112,7 @@ The runtime database is opened in read-only mode. If it is missing, unreadable, - The app depends on undocumented internal formats: the `v2/sessions` directory layout and the runtime database schema. A client update can change either, which may break session identification or parsing. - Session identification is an inference, not a binding. With one conversation active it is reliable; with none running it degrades to most-recently-written. - A single `messages.jsonl` is read up to 64 MB. Larger sessions are truncated and the page says so. -- The ledger does not use virtual scrolling; every rendered row is a real element. The page therefore opens on the newest 200 records, and the window bar button switches to the whole session. The button names what pressing it will do, not the current state: 「显示全部」 at the default and 「显示最新 200 条」 once everything is showing, while the count beside it reports what is on screen. That full view is opt-in for a reason: on a 9,900-record session it is roughly 57,000 elements rebuilt every 2.5s poll, which was measured to freeze the page rather than be read. 「向上加载更早的 N 条」 widens the window in steps and a poll does not undo it. A sticky window bar under the overview strip offers that choice backers four explicit controls: switch the strip between equal width and a real time axis, load 200 earlier records, jump back to the latest 200, and show everything. "Show everything" builds every record in the session into the DOM at once and gets noticeably slower on large sessions; incoming records do not knock it back down to 200. +- The ledger does not use virtual scrolling; every rendered row is a real element. The page therefore opens on the newest 200 records, and the window bar button switches to the whole session. The button names what pressing it will do, not the current state: 「显示全部」 at the default and 「显示最新 200 条」 once everything is showing, while the count beside it reports what is on screen. That full view is opt-in for a reason: on a 9,900-record session it is roughly 57,000 elements rebuilt every 2.5s poll, which was measured to freeze the page rather than be read. 「向上加载更早的 N 条」 widens the window in steps and a poll does not undo it. A sticky window bar under the overview strip offers that choice behind four explicit controls: switch the strip between equal width and a real time axis, load 200 earlier records, jump back to the latest 200, and show everything. "Show everything" builds every record in the session into the DOM at once and gets noticeably slower on large sessions; incoming records do not knock it back down to 200. - `messageCount` and `turnCount` in the session picker are estimates derived from a bounded prefix of the file; exact values come from the selected session. - Light theme token coverage is verified — every `--mcode-*` token the page consumes is defined for both themes — but its rendered appearance was not visually checked; the page was exercised under a dark system preference. diff --git a/plugins/avatasia/mmc-trajectory/README.zh-CN.md b/plugins/avatasia/mmc-trajectory/README.zh-CN.md index 4caabcf..bb26af5 100644 --- a/plugins/avatasia/mmc-trajectory/README.zh-CN.md +++ b/plugins/avatasia/mmc-trajectory/README.zh-CN.md @@ -22,7 +22,7 @@ 台账按 `turn_id` 把记录分组,轮次划分完全依据会话文件中记录的 `turn_id`。每条记录被归类为用户、答复、过程、思考、工具调用、工具结果、系统或压缩;每个轮次表头带该轮自己的 token 计数与耗时。选中任意记录会打开详情面板,含摘要、原始 JSON、工具入参、工具结果与思考内容,并提供一个把该记录内容送回 Agent 对话输入框的按钮。 -**类型选项是唯一的筛选。** 被过滤的始终只有一件事——哪些记录类型可见——客户端里它是同一个数组,只由三个控件写入:八个类型按钮、「全部」和「清除筛选」。早期版本在列表上方还排了一行图层按钮(轮次 / 思考 / 调用),作为同一批类型选项的快捷预设,已经删除。任何读者能走偏的预设,最终都会变成一个与它本该概括的选项自相矛盾的按钮:只取消「过程」而保留「用户」,轮次按钮就熄灭并声称整个对话已隐藏,而实际上你只是收窄了它。一个不会跑偏的控件,比三个方便的控件更值钱。 +**类型选项是唯一针对「类型」的筛选。** 被过滤的始终只有一件事——哪些记录类型可见——客户端里它是同一个数组,只由三个控件写入:八个类型按钮、「全部」和「清除筛选」。早期版本在列表上方还排了一行图层按钮(轮次 / 思考 / 调用),作为同一批类型选项的快捷预设,已经删除。任何读者能走偏的预设,最终都会变成一个与它本该概括的选项自相矛盾的按钮:只取消「过程」而保留「用户」,轮次按钮就熄灭并声称整个对话已隐藏,而实际上你只是收窄了它。一个不会跑偏的控件,比三个方便的控件更值钱。 另外两个控件并不在类型之上做筛选,而是横跨类型:时间区间把台账限制在一个墙上时钟窗口内;异常筛选挑出的是「出了问题的记录」—— 工具失败、上下文压缩、整轮没有答复、答复是空的 —— 而不是某一类记录。两者都不改写类型数组,所以每个类型选项始终只回答一个问题。 直接点名类型只多花一次点击,却能到达预设原本覆盖的全部范围:勾选「用户」**和**「答复」就是对话,两个都取消就是没有对话,中间任意散落的子集(只看用户提问、只看系统记录)同样可以。 @@ -57,9 +57,9 @@ KPI 卡片里各标清了各自是什么:大数字是你打过字的轮数, ## 数据与访问 -**读取的文件。** 服务按以下顺序解析会话根目录:环境变量 `MAVIS_HOME`,其次是家目录下的 `.minimax`,最终得到 `/v2/sessions`。环境变量 `MMC_TRAJECTORY_ROOT` 可以整体覆盖该路径,供测试使用。每个会话目录下读取 `messages.jsonl`(活跃那一代)、`manifest.json`(会话 id 与创建时间)、`history-catalog.json`(上下文代清单,以及每个 artifact 已提交的字节长度)。从活跃代出发能走到的每一份快照,随后会按清单记录的长度为界从 `snapshots/` 读取;而判断某个候选是否属于这条代链,只需要读它的前 64 KB。另外**只读**打开 `/v2/sqlite/runtime-state.sqlite`,用于判定哪段对话正在运行并读取真实会话标题。 +**读取的文件。** 服务按以下顺序解析会话根目录:环境变量 `MAVIS_HOME`,其次是家目录下的 `.minimax`,最终得到 `/v2/sessions`。环境变量 `MMC_TRAJECTORY_ROOT` 覆盖的是数据目录本身,供测试使用;`v2/sessions` 与 `v2/sqlite` 仍在它下面解析。每个会话目录下读取 `messages.jsonl`(活跃那一代)、`manifest.json`(会话 id 与创建时间)、`history-catalog.json`(上下文代清单,以及每个 artifact 已提交的字节长度)。从活跃代出发能走到的每一份快照,随后只受应用自身的 64 MB 上界约束从 `snapshots/` 读取,不再以清单记录的长度为界;而判断某个候选是否属于这条代链,只需要读它的前 64 KB。从未发生过压缩的会话根本没有 `history-catalog.json`,此时只读它的活跃文件。另外**只读**打开 `/v2/sqlite/runtime-state.sqlite`,用于判定哪段对话正在运行并读取真实会话标题。 -目录清单里的长度用来给读取范围设上界,避免正在被追加写入的文件被解析到半行;无法解析的行会被跳过并计数。会话目录名是 `base64url(sessionId)`,因此单凭文件名就能还原会话 id;服务在读取任何文件之前,会拿它与 `manifest.json`、数据库三方交叉校验。 +读取范围由 `MAX_MESSAGE_BYTES` 设上界,`readHead` 再把每次读取收口到最后一个完整换行,避免正在被追加写入的文件被解析到半行;无法解析的行会被跳过并计数。上限由服务端随 payload 一起下发、页面按实际值渲染,而不是在两边各写一个数字再各自漂移。会话目录名是 `base64url(sessionId)`,因此单凭文件名就能还原会话 id;服务在读取任何文件之前,会拿它与 `manifest.json`、数据库三方交叉校验。 快照文件名来自 `history-catalog.json`,那是数据而不是代码。因此一个文件名只有在「是不含路径分隔符与 `..` 的纯 basename」且「解析后的路径仍位于会话目录之内」这两条同时成立时才会被使用;两道检查都通过之后才会打开文件。 @@ -81,9 +81,9 @@ KPI 卡片里各标清了各自是什么:大数字是你打过字的轮数, ## 诊断 -Node 运行时另外提供一个诊断路由 `GET /api/runtime`,报告该进程能观测到的自身会话身份信息:Node 版本、环境变量的数量与名称、是否有环境变量值或 argv 条目形似会话 id、以及运行时数据库能否打开、最终解析出了什么。它只返回名称与结构 —— 文件系统位置一律压缩成纯文件名,响应里不含任何绝对路径。 +Node 运行时另外提供一个诊断路由 `GET /api/runtime`,报告该进程能观测到的自身会话身份信息:Node 版本、运行时数据库能否打开、最终解析出了哪个会话,以及工作目录与数据目录的 basename。它只返回名称与结构 —— 文件系统位置一律压缩成纯文件名,响应里不含任何绝对路径、进程 id 或命令行参数。早期版本还会返回环境变量名并扫描它们是否形似会话 id;这些字段已删除 —— 把宿主进程的环境暴露给页面本身就是风险,而那次扫描并没有回答这个路由现在仍然需要的任何问题。 -监听端口由 Host 在启动时分配,并以 `miniapp.runtime.listening` 记录日志。这个路由正是下一节那些结论能被「坐实」而不是「假设」的原因;当 MiniMax Code 的某个版本改变了运行时的拉起方式时,它也是第一个该查的地方。在已验证的版本上,它报告 13 个环境变量名,其中没有任何环境变量值、也没有任何 argv 条目能匹配上会话 id。 +监听端口由 Host 在启动时分配,并以 `miniapp.runtime.listening` 记录日志。这个路由正是下一节那些结论能被「坐实」而不是「假设」的原因;当 MiniMax Code 的某个版本改变了运行时的拉起方式时,它也是第一个该查的地方。 ## 当前会话如何确定 diff --git a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs index 99bd29e..78d7bd1 100644 --- a/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs +++ b/plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs @@ -1638,15 +1638,19 @@ export async function start(context) { sqlite.dbFile = basename(join(location.root, 'v2', 'sqlite', 'runtime-state.sqlite')); const sqlitePath = join(location.root, 'v2', 'sqlite', 'runtime-state.sqlite'); sqlite.exists = existsSync(sqlitePath); + let probe = null; try { - const probe = new mod.DatabaseSync(sqlitePath, { readOnly: true }); + probe = new mod.DatabaseSync(sqlitePath, { readOnly: true }); const count = probe.prepare( "select count(*) as n from local_runtime_sessions where session_kind = 'conversation' and purpose is null", ).get(); sqlite.conversationCount = count ? toNumber(count.n) : null; - probe.close(); } catch (error) { sqlite.queryError = String(/** @type {any} */ (error)?.message ?? error).slice(0, 240); + } finally { + // A schema mismatch is the case this route exists to report, and it is exactly the case + // where `prepare` throws — so closing inside the try left the handle waiting for GC. + closeQuietly(probe); } const current = await resolveCurrentConversation(location.root); sqlite.opened = Boolean(current); @@ -1736,13 +1740,21 @@ export async function start(context) { entry = entries.find((candidate) => basename(candidate.dir).includes('session_') && decodeSessionIdFromDirName(basename(candidate.dir)) === current.sessionId) ?? null; if (!entry && current.relativeDir) { - // Not in the recent window — build the entry straight from the recorded path. - const dir = join(location.sessionsRoot, current.relativeDir.replace(/\\/g, '/')); - try { - const fileStat = await stat(join(dir, 'messages.jsonl')); - entry = { dir, sizeBytes: fileStat.size, lastActiveAt: Math.floor(fileStat.mtimeMs) }; - } catch { + // Not in the recent window — build the entry straight from the recorded path. That value + // is a database column rather than a name this app chose, so it earns the same + // containment check `resolveArtifactPath` gives catalog file names. + const root = resolve(location.sessionsRoot); + const dir = resolve(root, current.relativeDir.replace(/\\/g, '/')); + if (dir !== root && !dir.startsWith(root + sep)) { + context.logger.warn('miniapp.trajectory.read_error', { reason: 'session_dir_rejected' }); entry = null; + } else { + try { + const fileStat = await stat(join(dir, 'messages.jsonl')); + entry = { dir, sizeBytes: fileStat.size, lastActiveAt: Math.floor(fileStat.mtimeMs) }; + } catch { + entry = null; + } } } if (entry) binding = current; @@ -1811,6 +1823,22 @@ export async function start(context) { // to learn which generation it claims to be, so only the files that actually belong to the // chain get parsed in full. const artifactByFile = new Map(generations.map((candidate) => [candidate.fileName, candidate])); + // history-catalog.json is written by the runtime when it rotates generations, so a session + // that has never compacted does not have one and its `generations` list is empty. The active + // file is real regardless, and `probe` below already synthesises an artifact for it; reuse + // that same shape here instead of rejecting the whole session with artifact_path_rejected. + const activeArtifactFor = (fileName) => artifactByFile.get(fileName) + ?? (fileName === 'messages.jsonl' + ? { + fileName: 'messages.jsonl', + active: true, + generation: null, + kind: 'messages', + byteLength: null, + messageCount: null, + revision: null, + } + : null); /** @type {Map} */ const probed = new Map(); const probe = async (generation) => { @@ -1842,7 +1870,7 @@ export async function start(context) { return; } const activeFileName = lineage.chain[lineage.chain.length - 1].fileName; - const activeArtifact = artifactByFile.get(activeFileName) ?? null; + const activeArtifact = activeArtifactFor(activeFileName); // Concatenate oldest → newest, which is the order the conversation actually happened in. /** @type {Array>} */ @@ -1851,7 +1879,7 @@ export async function start(context) { let truncated = false; let readBytes = 0; for (const link of lineage.chain) { - const artifact = artifactByFile.get(link.fileName) ?? null; + const artifact = activeArtifactFor(link.fileName); const filePath = artifact === null ? null : resolveArtifactPath(entry.dir, artifact); if (filePath === null) { context.logger.error('miniapp.trajectory.read_error', { reason: 'artifact_path_rejected', code: errorCode(null) });