diff --git a/CHANGELOG.md b/CHANGELOG.md index ddfce2f..bbfb186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). 中文版见 [CHANGELOG_ZH.md](./CHANGELOG_ZH.md). +## [1.1.8] - 2026-10-21 + +### Fixed + +- **DSH 0.2.x compatibility (plugin admission gate)** — DSH 0.2.1-rc.2 only loads a plugin when `package.json` declares `engines.dsh`, or when every `peerDependencies` range whose package name is `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` matches the running DSH runtime version. 1.1.7 declared all four DSH client peers with `>=0.1.0-rc.6 <0.2.0`, so startup was denied with the user-visible warning `dsh-usage-chart@1.1.7 与 DSH 0.2.1-rc.2 不兼容(要求 @deepseek-ai/dsh-client-locale >=0.1.0-rc.6 <0.2.0, ...)…`. The peer ranges now cover both client-SDK generations: + - `@deepseek-ai/dsh-client-locale`, `@deepseek-ai/dsh-client-ui-conversation`, and `@deepseek-ai/dsh-client-ui-slots` widened from `>=0.1.0-rc.6 <0.2.0` to `>=0.1.0-rc.6 <0.3.0-0`. The `-0` upper bound matters because DSH compares ranges with `includePrerelease`: it still admits 0.2.x prereleases (like 0.2.1-rc.2) but keeps future 0.3.0 prereleases out. + - The retired `@deepseek-ai/dsh-client-runtime` peer is removed. DSH 0.2.x replaced it with the `dsh-client-modules` module system (plus `dsh-client-ui-renderer`, the `dsh-client-ui-session` standard kit, and `dsh-client-store`); its last published version was `0.1.1-rc.2`, so no 0.2.x-compatible release exists. + - DSH 0.1.x stays supported on the code level: the 0.1.x paths (`provenance`, `assistant/chunk`, string `user/message.source`) are kept, and the APIs the plugin uses are the same on both lines. The 0.2.1-rc.2 side was verified against the installed app bundle and the published 0.2.1-rc.2 package typings: slots `conversation.composer.dock` and `conversation.chat.assistant-actions` still exist and render; session-scope standard props still deliver `useSession` / `sessionId` / `useProjection` (ui-session) and `useChat` (ui-chat); `useChat` snapshots still expose `legacy.nodes`; the `tokenUsage` / `contextPressure` / `contextBreakdown` projections keep their fields; `ctx.locale.getLocale()` / `subscribe` and `ctx.slots.inject` / `register` are unchanged; and the host `webServer.register` / `sessions.get` / `snapshotEvents` / `credentials.resolve` services are unchanged. No live 0.1.x install was re-run after `dsh-client-runtime` left `dsh.client.inject`. +- **Assistant-node model identity on DSH 0.2.x** — the assistant-node model identity now also reads the 0.2.x `providerMetadata` field. The DSH 0.1.x `provenance` source is still preferred when present, and `requestConfig` remains the last-resort fallback, so model labels and per-message cost attribution no longer lose the model on 0.2.x. +- **Self-contained client type declarations** — the emitted client `.d.ts` files no longer import the retired `@deepseek-ai/dsh-client-runtime` package. Type-checking the plugin in a 0.2.x-only DSH tree can no longer fail on a dangling import of a package DSH no longer ships; 0.1.x trees keep working as before. +- **DSH 0.2.x session-log vocabulary** — DSH 0.2.x dropped `assistant/chunk` events and folds streaming usage into `assistant/message` (`usage` + `stream`). The host fold now follows the official token-meter accounting on these logs: + - Message usage comes from `data.usage`, falling back to the last usage record in `stream`. + - Failed or retried requests, which 0.2.x persists only as `assistant/attempt` with usage inside `stream`, are counted in totals and per-round costs, and each attempt is billed separately across retry boundaries. + - TTFT is the first token-bearing stream record: structural `block-start` / `usage` / `finish` records and empty fragments are skipped, while raw `*-delta` chunks and name-bearing tool-call fragments count even when their argument text is empty. + - Output TPS divides output tokens by the summed streaming time of each `assistant/message` / `assistant/attempt`, so tool execution and the wait between steps no longer drag it down; a turn whose output arrives as a single fragment reports no TPS. Logs without a stream timeline keep the 0.1.x anchors (first usage sample → turn end). + - `user/message.source` changed from a string to a `{ kind }` object. `user` and `user-*` map to `human`, goal/continuation and agent kinds keep their name, and other runtime injections such as `tool-jobs` become `agent.`, so the input-source label no longer shows them as human. + +### Tests + +- New manifest tests assert the widened peer ranges, that the retired `@deepseek-ai/dsh-client-runtime` peer/inject entry is gone, and that the built client bundle and published client type declarations no longer reference the retired package. New fold tests feed DSH 0.2.x-shaped logs (no `assistant/chunk`, `assistant/message` / `assistant/attempt` with `usage` + `stream`, object `user/message.source`) and assert totals, stream-only usage, TTFT from token-bearing records (raw delta chunks, name-bearing tool-call fragments), TPS over streaming time across steps, failed-attempt and retry billing, and source normalization; the retired-runtime check now scans every published client `.d.ts`; a client test covers the `provenance` → `providerMetadata` → `requestConfig` model fallback. `npm run verify` passes (typecheck + build + `node --test`, all 55 tests). The compatibility surface was checked against the installed DSH 0.2.1-rc.2 app bundle and the published 0.2.1-rc.2 package typings, and a headless-browser smoke test of a DSH 0.2.1-rc.2 Web instance loaded the linked 1.1.8 build: the boot graph included the plugin, the dock indicator rendered live usage/cost against a real session (zh copy through the `locale` service) and the expanded panel rendered session totals, context breakdown and the token projections, with no plugin console errors. + ## [1.1.7] - 2026-09-24 ### Fixed diff --git a/CHANGELOG_ZH.md b/CHANGELOG_ZH.md index 9d53802..b5912f1 100644 --- a/CHANGELOG_ZH.md +++ b/CHANGELOG_ZH.md @@ -3,6 +3,27 @@ 本文件记录本项目所有重要变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。英文版见 [CHANGELOG.md](./CHANGELOG.md)。 +## [1.1.8] - 2026-10-21 + +### 修复 + +- **兼容 DSH 0.2.x(插件准入校验)**——DSH 0.2.1-rc.2 在启动时执行插件准入校验:除非 `package.json` 声明了 `engines.dsh`,否则所有包名为 `@deepseek-ai/dsh` 或 `@deepseek-ai/dsh-*` 的 `peerDependencies` 范围都必须匹配当前运行的 DSH 运行时版本。1.1.7 的 4 个 DSH client peer 都声明为 `>=0.1.0-rc.6 <0.2.0`,因此启动时被拒绝,并给出用户可见警告:`dsh-usage-chart@1.1.7 与 DSH 0.2.1-rc.2 不兼容(要求 @deepseek-ai/dsh-client-locale >=0.1.0-rc.6 <0.2.0, ...)…`。现在 peer 范围同时覆盖两代 client SDK: + - `@deepseek-ai/dsh-client-locale`、`@deepseek-ai/dsh-client-ui-conversation`、`@deepseek-ai/dsh-client-ui-slots` 的 peer 范围由 `>=0.1.0-rc.6 <0.2.0` 放宽为 `>=0.1.0-rc.6 <0.3.0-0`。上界带 `-0` 是因为 DSH 以 `includePrerelease` 比较范围:这样仍接受 0.2.x 预发布(如 0.2.1-rc.2),但不会把未来的 0.3.0 预发布提前放行。 + - 移除已退役的 `@deepseek-ai/dsh-client-runtime` peer。DSH 0.2.x 用 `dsh-client-modules` 模块系统(以及 `dsh-client-ui-renderer`、`dsh-client-ui-session` 标准套件与 `dsh-client-store`)取代了它;该包最后发布的版本为 `0.1.1-rc.2`,不存在兼容 0.2.x 的版本。 + - 在代码层面继续支持 DSH 0.1.x:保留 0.1.x 的 `provenance`、`assistant/chunk` 与字符串型 `user/message.source` 路径,插件使用的接口在两条线上一致。0.2.1-rc.2 侧已对照本机安装的应用包与已发布的 0.2.1-rc.2 包类型声明核实:`conversation.composer.dock`、`conversation.chat.assistant-actions` 槽位仍存在且正常渲染;会话作用域标准属性仍提供 `useSession` / `sessionId` / `useProjection`(ui-session)与 `useChat`(ui-chat);`useChat` 快照仍暴露 `legacy.nodes`;`tokenUsage` / `contextPressure` / `contextBreakdown` 投影字段不变;`ctx.locale.getLocale()` / `subscribe` 与 `ctx.slots.inject` / `register` 未变;宿主 `webServer.register` / `sessions.get` / `snapshotEvents` / `credentials.resolve` 服务未变。把 `dsh-client-runtime` 移出 `dsh.client.inject` 后,未在 0.1.x 上重新做实机安装测试。 +- **适配 DSH 0.2.x 的助手节点模型识别**——助手节点的模型标识新增读取 0.2.x 的 `providerMetadata` 字段;存在 DSH 0.1.x 的 `provenance` 字段时仍优先使用,`requestConfig` 继续作为最后回退,0.2.x 下模型名称与逐条消息成本归因不再丢失。 +- **client 类型声明自包含**——生成的 client `.d.ts` 不再 import 已退役的 `@deepseek-ai/dsh-client-runtime` 包。在仅安装 0.2.x 的 DSH 环境做类型检查时,不会再因悬空导入(DSH 已不再提供该包)而失败;0.1.x 环境行为保持不变。 +- **适配 DSH 0.2.x 会话日志词汇**——DSH 0.2.x 移除了 `assistant/chunk` 事件,流式用量合并进 `assistant/message`(`usage` + `stream`)。宿主折叠现按官方 token-meter 的口径处理这类日志: + - 消息用量取 `data.usage`,缺席时回退到 `stream` 里最后一条 usage 记录。 + - 0.2.x 把失败/重试的请求只记为 `assistant/attempt`(用量内嵌在 `stream` 中),这些用量现在也计入会话总量与每轮成本,重试前后的每次尝试分别计费。 + - TTFT 取流里首个携带 token 的记录:跳过 `block-start` / `usage` / `finish` 等结构记录与空片段;原始 `*-delta` chunk 与带工具名的 tool-call 片段即使参数为空也算 token。 + - 输出吞吐 = 输出 token ÷ 各条 `assistant/message` / `assistant/attempt` 流式时长之和,不再被工具执行与步骤间等待拉低;整轮输出只有一个片段时不报吞吐。没有流时间线的日志沿用 0.1.x 口径(首个 usage 样本 → 轮次结束)。 + - `user/message.source` 由字符串改为 `{ kind }` 对象:`user` 与 `user-*` 归为 `human`,目标续跑与 Agent 类来源保留原名,`tool-jobs` 等其他运行时注入记为 `agent.`,来源标签不再把它们显示为人工。 + +### 测试 + +- 新增 manifest 测试断言放宽后的 peer 范围、退役的 `@deepseek-ai/dsh-client-runtime` peer/inject 条目已移除,以及构建后的 client bundle 与发布的 client 类型声明均不再引用该退役包。新增折叠测试喂入 DSH 0.2.x 形状的日志(无 `assistant/chunk`、`assistant/message` / `assistant/attempt` 带 `usage` + `stream`、`user/message.source` 为对象),断言 token 汇总、仅在流中的用量、由携带 token 的记录(原始 delta chunk、带工具名的 tool-call 片段)推导的 TTFT、跨步骤按流式时长计算的 TPS、失败尝试与重试计费,以及来源归一化;退役 runtime 检查改为扫描全部发布的 client `.d.ts`;另有 client 测试覆盖 `provenance` → `providerMetadata` → `requestConfig` 模型回退。`npm run verify` 全部通过(typecheck + build + `node --test`,共 55 项测试)。兼容面已对照本机安装的 DSH 0.2.1-rc.2 应用包与已发布的 0.2.1-rc.2 包类型声明核实;并在 DSH 0.2.1-rc.2 Web 实例上用 headless 浏览器完成加载冒烟测试:boot 图中包含本插件,输入框下方指示器在真实会话上渲染出实时用量/成本(经 `locale` 服务显示中文),展开面板渲染出会话汇总、上下文构成与投影数据,且无插件控制台错误。 + ## [1.1.7] - 2026-09-24 ### 修复 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2a4742..3afd666 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,4 +19,4 @@ environment variables — see the README's development section for the table. ## Compatibility -This plugin follows DSH 0.1.x Host/Client bundle conventions. Changes to injected services, slots, module-loader externals, or `cordis.patch.yml` must include an installation smoke test against a clean Web profile. +This plugin supports DSH 0.1.x and 0.2.x Host/Client bundle conventions; its peer ranges cover both. `@deepseek-ai/dsh-client-runtime` was retired in DSH 0.2.0 — do not reintroduce imports, build externals, or peer entries for it. Changes to injected services, slots, module-loader externals, or `cordis.patch.yml` must include an installation smoke test against a clean Web profile. diff --git a/README.md b/README.md index 1461dcc..91c435d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Click ▸ to open the dashboard panel: - **Cost estimation** — Estimated from official list prices (CNY/USD dual-currency per 1M tokens, peak/off-peak tiers) with verified source date; supports user override via `pricing.json`; unpriced models are explicitly tagged. - **Session-cost aggregation (v1.1.7)** — Adds the host's per-round costs using each round's model and billing tier. While a new round is in progress, the indicator and panel share the fetched history plus a marked estimate for new tokens, then refresh the history after token updates pause. The model label follows the same current-history/live-node priority in both places. - **DSH 0.1.2+ and themed docks (v1.1.7)** — Reads conversation nodes from the independent `chat` source on newer DSH versions, with a legacy session-snapshot fallback; keeps the expanded panel anchored when a theme creates a fixed-position containing block. +- **DSH 0.2.x client-SDK compatibility (v1.1.8)** — Declares client peer ranges spanning both DSH 0.1.x and 0.2.x (tested against 0.2.1-rc.2) so DSH 0.2.x's plugin admission gate accepts the plugin at startup; the retired `@deepseek-ai/dsh-client-runtime` peer is gone, and the assistant-node model identity now also reads the 0.2.x `providerMetadata` field (0.1.x `provenance` still preferred when present, `requestConfig` kept as the last fallback). - **Peak / off-peak tiered billing (v1.0.1)** — Peak hours (Beijing time Monday–Friday 09:00–12:00 and 14:00–18:00, UTC 01:00–04:00 and 06:00–10:00) billed at 2× the off-peak rate; all other hours and weekends billed at off-peak rates; rounds bill automatically based on start time (or conservative peak if unknown); live red/green tag in the panel header. - **Official dual-currency list pricing (v1.0.1)** — Builtin official CNY and USD prices directly used according to the active display currency — **no FX conversion applied to costs** (matching official billing); "Refresh rate" updates only the informational "1 USD ≈ X CNY" reference note. - **Multi-currency display (v0.3 / v1.0.1)** — One-click toggle between USD and CNY (persisted in localStorage); indicator, panel, chart, and badges all follow. @@ -48,10 +49,10 @@ Click ▸ to open the dashboard panel: | Token usage | DSH official adapter session projections (`tokenUsage` / `contextPressure`) | ✅ Official real-time data | | Cost | Official list price (builtin + optional `pricing.json` override, CNY/USD dual-currency / 1M tokens, peak/off-peak tiers) × reported usage | ⚠️ Estimate, not invoice; resolved via Host `/pricing` snapshot | | Display currency | Host `/meta` config; costs directly calculated in selected currency list price | ✅ Official dual-currency list price | -| Per-round history | Host session log fold (`/usage`): duration / TTFT / TPS / model attribution / **input source attribution** / end reason / per-round cost | ✅ Official event stream fold | +| Per-round history | Host session log fold (`/usage`): duration / TTFT / TPS / model attribution / **input source attribution** / end reason / per-round cost. DSH 0.2.x derives TTFT/TPS from `assistant/message.stream`; 0.1.x falls back to chunk/usage anchors | ✅ Official event stream fold | | Context & compaction | Official `contextBreakdown` / `contextPressure` projections + Host `compaction/*` event fold | ✅ Official projections + event fold | | Balance | Official `GET https://api.deepseek.com/user/balance` | ✅ Official real-time data | -| Model name | Adapter request provenance / `request/context` | ✅ Official real-time data | +| Model name | Adapter `providerMetadata` (DSH 0.2.x) / request provenance / `request/context` (DSH 0.1.x) | ✅ Official real-time data | ## Tech stack @@ -74,11 +75,11 @@ dsh plugin --profile web add dsh-usage-chart # installs and registers the prof dsh web --profile web # starts DSH Web (stop it first if already running) ``` -To update after 1.1.7 is published: pnpm may print `Already up to date` when already installed — use an **explicit version** (recommended) or **remove then re-add**: +To update after 1.1.8 is published: pnpm may print `Already up to date` when already installed — use an **explicit version** (recommended) or **remove then re-add**: ```sh # Option ①: pin the target version explicitly -dsh plugin --profile web add dsh-usage-chart@1.1.7 +dsh plugin --profile web add dsh-usage-chart@1.1.8 # Option ②: remove, then re-add (back to latest) dsh plugin --profile web remove dsh-usage-chart dsh plugin --profile web add dsh-usage-chart @@ -191,6 +192,9 @@ A: `npx @deepseek-ai/dsh` runs transiently and installs no global command. Run ` **Q: Install shows `WARN missing peer react@^18.2.0`?** A: Harmless — react is provided by the DSH Web platform in the browser; the profile does not need it. Plugin ≥ 0.1.1 marks react as an optional peer. +**Q: DSH 0.2.x warns `dsh-usage-chart@1.1.7 与 DSH 0.2.1-rc.2 不兼容(要求 @deepseek-ai/dsh-client-locale >=0.1.0-rc.6 <0.2.0, ...)…` — is the plugin incompatible?** +A: That warning comes from DSH 0.2.x's **plugin admission gate**, not from a damaged DSH install and not from data loss: DSH refuses to load a plugin whose `@deepseek-ai/dsh*` peer ranges do not match the running runtime version, and plugin 1.1.x declared 0.1.x-only ranges. Upgrade the plugin to **≥ 1.1.8** (`dsh plugin --profile web add dsh-usage-chart@1.1.8`), then restart `dsh web` and reload the DSH Web page if it was already open. DSH itself was not damaged and no session data, costs, or credentials were lost — the plugin simply was not loaded. + **Q: The balance still shows `–` / "not configured" after setting the API key in the web UI?** A: Make sure the plugin is ≥ 0.1.1, then restart `dsh web`. As a stopgap, set `DEEPSEEK_API_KEY` or `config.apiKey`. @@ -280,7 +284,7 @@ dsh-usage-chart/ | Component | Supported | |---|---| -| DSH | ≥ 0.1.0-rc.6, built against the 0.1.x API; adapts to the `dsh-session` `snapshotEvents()` API — `Session.events` was removed in `0.1.2-rc.1`, so the `/usage` route reads events via `snapshotEvents()` with a legacy `events` fallback | +| DSH | ≥ 0.1.0-rc.6, including 0.2.x (tested against 0.2.1-rc.2); peer ranges cover both the 0.1.x and 0.2.x client SDKs (v1.1.8), and the `/usage` route reads events via the `dsh-session` `snapshotEvents()` API with a legacy `events` fallback (`Session.events` was removed in `0.1.2-rc.1`) | | Node.js | ≥ 20 | | Web UI | React 18 / `conversation.composer.dock` + `conversation.chat.assistant-actions` | | OS | macOS, Linux, Windows (pure JavaScript, no native dependencies) | diff --git a/README_ZH.md b/README_ZH.md index 4c8db16..82472a8 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -32,6 +32,7 @@ - **成本估算** — 按官方刊例价(CNY/USD 双币种 /1M tokens,高峰/空闲双时段)估算并标注来源与核验日期;支持用户覆盖 `pricing.json`;未定价模型显式标记「未定价模型」 - **会话成本按轮汇总(v1.1.7)** — 按每轮实际模型和计费时段累加宿主成本;新轮进行中,指示器和面板共用“已读取历史+新增 token 的估算”,实时用量暂停更新后刷新宿主历史。两处模型名称采用相同的历史/实时来源顺序。 - **DSH 0.1.2+ 与皮肤兼容(v1.1.7)** — 新版从独立 `chat` 源读取会话节点,旧版回退到会话快照;皮肤创建 fixed 定位包含块时,展开面板仍贴近指示器。 +- **兼容 DSH 0.2.x client SDK(v1.1.8)** — client peer 范围同时覆盖 DSH 0.1.x 与 0.2.x(已在 0.2.1-rc.2 上验证),DSH 0.2.x 的插件准入校验在启动时即可通过;已移除退役的 `@deepseek-ai/dsh-client-runtime` peer,助手节点模型标识新增读取 0.2.x 的 `providerMetadata` 字段(存在 0.1.x 的 `provenance` 时仍优先,`requestConfig` 保留为最后回退)。 - **高峰/空闲时段计费(v1.0.1)** — 官方高峰时段(北京时间周一至周五 09:00–12:00、14:00–18:00,即 UTC 01:00–04:00、06:00–10:00)价格为空闲时段的 2 倍;其余时段及周末全天为空闲时段;每轮成本按轮次开始时刻自动选用对应时段单价(时刻缺失按高峰保守估算);面板顶部以**红/绿 tag 实时标注当前计费时段**(红=高峰、绿=空闲),每轮解释卡也显示该轮计费时段 - **双币种官方刊例价(v1.0.1)** — 中文定价页(CNY)与英文定价页(USD)的官方报价同时内置:CNY 显示用人民币报价、USD 显示用美元报价,**不做汇率换算**(与官方账单口径一致);「刷新汇率」仅更新「1 USD ≈ X CNY」参考注记 - **多币种成本(v0.3 / v1.0.1)** — 成本区 CNY/USD 一键切换(选择在浏览器记住),指示器、面板、图表与成本徽章全部跟随所选币种 @@ -48,10 +49,10 @@ | token 用量 | DSH 官方 adapter 上报的会话投影(`tokenUsage` / `contextPressure`) | ✅ 官方真实数据,实时更新 | | 成本 | 官方刊例价(内置表 + 可选用户覆盖 `pricing.json`,CNY/USD 双币种 /1M tokens,高峰/空闲双时段)× adapter 上报用量 | ⚠️ 估算值,非官方账单;价格经宿主 `/pricing` 单点解析 | | 成本币种 | 宿主 `/meta` 下发配置;成本按所选币种的官方刊例价直接计算(不做汇率换算) | ✅ 官方双币种刊例价 | -| 轮次明细 | 宿主会话日志折叠(`/usage`):耗时 / TTFT / TPS / 模型归因 / **输入来源归因** / 结束原因 / 每轮成本 | ✅ 官方事件流折叠 | +| 轮次明细 | 宿主会话日志折叠(`/usage`):耗时 / TTFT / TPS / 模型归因 / **输入来源归因** / 结束原因 / 每轮成本。DSH 0.2.x 用 `assistant/message.stream` 推导 TTFT/TPS,0.1.x 回退到 chunk/usage 锚点 | ✅ 官方事件流折叠 | | 上下文与压缩诊断 | 官方 `contextBreakdown` / `contextPressure` 投影 + 宿主 `compaction/*` 事件折叠 | ✅ 官方投影 + 事件流折叠 | | 余额 | 官方 `GET https://api.deepseek.com/user/balance` | ✅ 官方实时数据 | -| 模型名 | adapter 上报的请求 provenance / `request/context` | ✅ 官方真实数据 | +| 模型名 | adapter 上报的 `providerMetadata`(DSH 0.2.x)/ 请求 provenance / `request/context`(DSH 0.1.x) | ✅ 官方真实数据 | ## 技术栈 @@ -75,12 +76,12 @@ dsh plugin --profile web add dsh-usage-chart # 安装并自动登记为 profil dsh web --profile web # 启动 DSH Web(已在运行时先停止再启动) ``` -1.1.7 发布后更新:pnpm 对已安装的依赖重新 `add` 可能显示 `Already up to date` +1.1.8 发布后更新:pnpm 对已安装的依赖重新 `add` 可能显示 `Already up to date` 而不升级,请用**显式版本**(推荐)或**先卸载再安装**: ```sh # 方式①:显式指定目标版本 -dsh plugin --profile web add dsh-usage-chart@1.1.7 +dsh plugin --profile web add dsh-usage-chart@1.1.8 # 方式②:先移除再重装(回到最新版) dsh plugin --profile web remove dsh-usage-chart dsh plugin --profile web add dsh-usage-chart @@ -93,7 +94,7 @@ dsh plugin --profile web add dsh-usage-chart > ⚠️ **未全局安装 dsh(报 `dsh: command not found` / PowerShell `无法将“dsh”项识别为…`)? > 把上面每条 `dsh` 都写成 `npx --yes @deepseek-ai/dsh`**,例如 -> `npx --yes @deepseek-ai/dsh plugin --profile web add dsh-usage-chart@1.1.7` +> `npx --yes @deepseek-ai/dsh plugin --profile web add dsh-usage-chart@1.1.8` > (原因与解法见 [FAQ](#常见问题faq) 第一条)。 ### 方式二:从 GitHub 安装(源码构建) @@ -235,6 +236,9 @@ A:`npx @deepseek-ai/dsh` 是临时运行、不产生全局命令。请 `npm in A:正常且无害——react 由 DSH Web 平台在浏览器端内置,profile 无需安装。插件 ≥ 0.1.1 已把 react 标记为可选 peer,不再报警;0.1.0 的该警告可忽略。 +**Q:DSH 0.2.x 提示 `dsh-usage-chart@1.1.7 与 DSH 0.2.1-rc.2 不兼容(要求 @deepseek-ai/dsh-client-locale >=0.1.0-rc.6 <0.2.0, ...)…`,是插件不兼容吗?** +A:该警告来自 DSH 0.2.x 的**插件准入校验**,不是 DSH 装坏了,也不会丢数据:当插件的 `@deepseek-ai/dsh*` peer 范围不匹配当前运行时版本时 DSH 会拒绝加载,而插件 1.1.x 声明的是仅 0.1.x 的范围。把插件升级到 **≥ 1.1.8**(`dsh plugin --profile web add dsh-usage-chart@1.1.8`)后重启 `dsh web`;若 DSH Web 页面已打开,再刷新页面即可。DSH 本身未受损,会话数据、成本记录与凭据均未丢失——插件只是没有被加载。 + **Q:网页端配了 API Key,余额仍显示 `–` 或「未配置」?** A:请确认插件版本 ≥ 0.1.1(0.1.1 起余额查询才走 DSH 凭据服务读取网页端密钥); 升级后重启 `dsh web`。临时方案:先设环境变量 `DEEPSEEK_API_KEY` 或 `config.apiKey`。 @@ -333,7 +337,7 @@ dsh-usage-chart/ | 组件 | 支持范围 | |---|---| -| DSH | ≥ 0.1.0-rc.6,当前按 0.1.x API 构建;已适配 `dsh-session` 的 `snapshotEvents()` API——`Session.events` 自 `0.1.2-rc.1` 起移除,`/usage` 路由通过 `snapshotEvents()` 读取事件,并保留旧版 `events` 属性回退 | +| DSH | ≥ 0.1.0-rc.6,含 0.2.x(已在 0.2.1-rc.2 上验证);peer 范围同时覆盖 0.1.x 与 0.2.x 两代 client SDK(v1.1.8),`/usage` 路由通过 `dsh-session` 的 `snapshotEvents()` API 读取事件,并保留旧版 `events` 属性回退(`Session.events` 自 `0.1.2-rc.1` 起移除) | | Node.js | ≥ 20 | | Web UI | React 18 / `conversation.composer.dock` + `conversation.chat.assistant-actions` | | 系统 | macOS、Linux、Windows(纯 JavaScript,无原生依赖) | diff --git a/build.mjs b/build.mjs index 152a608..2573b07 100644 --- a/build.mjs +++ b/build.mjs @@ -4,8 +4,8 @@ * * - lib/index.js —— host 半区(Node ESM,加载器按 package.json main 导入) * - lib/client.js —— client 半区(浏览器 CJS 工厂包,`window.__ModuleLoader__.load` - * 注册;外部依赖 = DSH web 的 PLATFORM_MODULES + runtime/client 豁免, - * 与 monorepo packages/client/tsdown.client.ts 的规则保持一致) + * 注册;外部依赖 = DSH web 0.1.x/0.2.x 浏览器共享模块表, + * 与官方 client 构建同一套 external 规则) * * 用法:node build.mjs (可选 DSH_PLUGIN_ID 覆盖注册 id,默认 dsh-usage-chart) */ @@ -19,9 +19,10 @@ const tscBin = require.resolve('typescript/bin/tsc') const PLUGIN_ID = process.env.DSH_PLUGIN_ID ?? 'dsh-usage-chart' -// 与 @deepseek-ai/dsh-client-web/src/platform.ts 的 PLATFORM_MODULES 完全一致 -// + RUNTIME_STORE_EXEMPTION(packages/client/tsdown.client.ts 的 CLIENT_EXTERNALS)。 -// 运行时这些 specifier 由浏览器的模块表(loader)回答,绝不内联。 +// DSH Web 浏览器共享模块表(0.1.x PLATFORM_MODULES 与 0.2.x 静态共享表的并集)。 +// 运行时这些 specifier 由浏览器模块表(loader)回答,绝不内联;本插件产物实际 +// 只 require react / react/jsx-runtime(见 lib/client.js),列表是共享模块契约。 +// 0.2.0 起 @deepseek-ai/dsh-client-runtime 已退役(最后版本 0.1.1-rc.2),不再列为外部。 const CLIENT_EXTERNALS = [ 'react', 'react/jsx-runtime', @@ -29,11 +30,12 @@ const CLIENT_EXTERNALS = [ 'react-dom/client', '@deepseek-ai/cordis', '@deepseek-ai/dsh-client-ui-slots', - '@deepseek-ai/dsh-client-web-react', '@deepseek-ai/dsh-client-ui-primitives', + '@deepseek-ai/dsh-client-ui-dockkit', + '@deepseek-ai/dsh-client-store', + '@deepseek-ai/dsh-client-web-react', '@deepseek-ai/dsh-client-ui-attachment', '@deepseek-ai/dsh-client-schema-form', - '@deepseek-ai/dsh-client-runtime/client', ] // A clean output directory prevents stale chunks or declarations from leaking diff --git a/package-lock.json b/package-lock.json index c8ec3a3..9cb77dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-usage-chart", - "version": "1.1.7", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-usage-chart", - "version": "1.1.7", + "version": "1.1.8", "license": "MIT", "devDependencies": { "@deepseek-ai/cordis": "^4.0.1", diff --git a/package.json b/package.json index cbff38b..584e925 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-usage-chart", - "version": "1.1.7", + "version": "1.1.8", "description": "DeepSeek 用量 / 成本 / 余额仪表盘 for DSH Web:输入框下方实时指标指示器 + 零依赖 SVG 用量可视化图表(含每轮成本视角、耗时叠加、异常标记、成本徽章、上下文压力条),token 数据来自官方 adapter 上报,成本按官方刊例价 + 用户覆盖 pricing.json 解析,余额来自官方 /user/balance 接口。", "type": "module", "sideEffects": false, @@ -77,7 +77,6 @@ }, "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-slots" @@ -87,19 +86,15 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", - "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.6 <0.2.0", - "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.6 <0.2.0", - "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.0-rc.6 <0.2.0", - "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.6 <0.2.0", + "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.6 <0.3.0-0", + "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.0-rc.6 <0.3.0-0", + "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.6 <0.3.0-0", "react": "^18.2.0" }, "peerDependenciesMeta": { "@deepseek-ai/cordis": { "optional": true }, - "@deepseek-ai/dsh-client-runtime": { - "optional": true - }, "@deepseek-ai/dsh-client-locale": { "optional": true }, diff --git a/src/client/UsageIndicator.tsx b/src/client/UsageIndicator.tsx index ede727f..084199d 100644 --- a/src/client/UsageIndicator.tsx +++ b/src/client/UsageIndicator.tsx @@ -13,28 +13,29 @@ import { resolveCost, usePricing } from './pricing-api.ts' import { useHistoryRounds } from './rounds/history.ts' import { useObservedRounds } from './rounds/observed.ts' import { lastRoundModel, sameUsage, sumRoundCosts, usageDelta } from './rounds/summary.ts' -import { useSessionNodes, type ChatNodesHook, type ConversationNode, type ConversationSnapshot } from './snapshot.ts' +import { nodeModelName, useSessionNodes, type ChatNodesHook, type ConversationNode, type ConversationSnapshot } from './snapshot.ts' import type { ContextBreakdownData } from './diagnose/context.ts' import { UsagePanel } from './UsagePanel.tsx' export interface DockUsageProps { - /** 会话快照选择器(framework 标准套件)。 */ + /** 会话快照选择器(framework 标准套件;0.1.x / 0.2.x 均由 ui-session 提供)。 */ useSession: (selector: (s: ConversationSnapshot) => S) => S - /** chat 快照选择器(framework 标准套件;0.1.2 起节点列表在这里)。 */ + /** chat 快照选择器(framework 标准套件;0.1.2+ / 0.2.x 由 ui-chat 提供,节点列表在 legacy.nodes)。 */ useChat?: ChatNodesHook - /** 投影读取钩子(framework 标准套件)。 */ + /** 投影读取钩子(framework 标准套件;键名与载荷在 0.1.x / 0.2.x 不变)。 */ useProjection: (key: 'tokenUsage' | 'contextPressure' | 'contextBreakdown') => unknown sessionId: string - session: ConversationSnapshot - input: unknown + /** 旧版(0.1.x)composer.dock owner props;0.2.x 不再传入,本插件不读取。 */ + session?: ConversationSnapshot + input?: unknown } function deriveModel(nodes: readonly ConversationNode[]): string | undefined { for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i] if (n.kind !== 'assistant') continue - if (n.provenance?.model !== undefined && n.provenance.model !== '') return n.provenance.model - if (n.requestConfig?.model !== undefined && n.requestConfig.model !== '') return n.requestConfig.model + const model = nodeModelName(n) + if (model !== undefined) return model } return undefined } diff --git a/src/client/context.ts b/src/client/context.ts new file mode 100644 index 0000000..823d96c --- /dev/null +++ b/src/client/context.ts @@ -0,0 +1,46 @@ +/** + * 浏览器端 DSH Client Context 的最小声明(自包含,不依赖退役的 + * `@deepseek-ai/dsh-client-runtime`,也不引用未随插件发布的 vendored 类型)。 + * + * DSH 0.1.x 与 0.2.x 的客户端插件都运行在 cordis Context 上;本插件只使用 + * `slots`(槽位注册)与 `locale`(活动语言订阅)两个服务,它们在 + * 0.1.0-rc.6 – 0.2.1-rc.2 间保持同一形状: + * - 0.1.x:由 dsh-client-runtime / dsh-client-ui-slots 提供; + * - 0.2.x:dsh-client-runtime 退役,服务改由 dsh-client-ui-renderer 的 + * SlotRegistry 与 dsh-client-locale 提供,注册/订阅接口不变。 + * 这里声明的是插件实际用到的子集;真实类型以运行时 DSH 为准。 + */ + +/** ui-slots 槽位注册选项(list 槽需要 id;order 控制同槽内顺序)。 */ +export interface ClientSlotRegisterOptions { + name: string + id?: string + order?: number + priority?: number + /** 诊断用途的注册者标签(0.1.x / 0.2.x 均接受)。 */ + registrant?: string + locale?: string +} + +/** ui-slots 的 SlotRegistry 服务(register / inject 均返回卸载函数)。 */ +export interface ClientSlotRegistry { + register(options: ClientSlotRegisterOptions, component: unknown): () => void + /** 等待某个槽位声明出现后注册(声明生命周期内生效,随调用方 fiber 卸载)。 */ + inject(key: string, callback: () => (() => void) | void): () => void +} + +/** dsh-client-locale 的 locale 服务(仅活动语言订阅面)。 */ +export interface ClientLocaleService { + /** 当前语言快照(active = 'zh' | 'en';语言切换或字典注册都会通知订阅者)。 */ + getLocale(): { active: string; locales: readonly { id: string }[]; revision: number } + /** 订阅快照变化(监听器无参调用,自行重读 getLocale;返回退订函数)。 */ + subscribe(listener: () => void): () => void +} + +/** 浏览器端 cordis Context(本插件客户端半区用到的面)。 */ +export interface ClientContext { + /** 注册一个资源释放器,插件卸载时执行。 */ + effect(disposer: () => (() => void) | void, label?: string): void + slots: ClientSlotRegistry + locale: ClientLocaleService +} diff --git a/src/client/index.ts b/src/client/index.ts index 283b565..0616ed3 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,8 +6,13 @@ * 本地化(DSH locale 体系):订阅 `locale` 服务的活动语言快照(getLocale + * subscribe),写入 i18n.ts 的 store;组件经 useUiLocale() 读取,随「设置 → * 语言」实时切换。可见文案只维护在 i18n.ts,避免平台注册字典与组件字典漂移。 + * + * 兼容性:DSH 0.1.x 与 0.2.x 的客户端插件同为 cordis Context。本插件只消费 + * `slots` / `locale` 两个服务和两个槽位名;0.2.x 起 `dsh-client-runtime` 已退役 + * (服务改由 ui-renderer 的 SlotRegistry 与 locale 提供),接口形状不变,因此 + * ClientContext 改为自包含的本地声明(见 ./context.ts),不再引用退役包。 */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext } from './context.ts' import { CostBadge } from './badge/CostBadge.tsx' import { UsageIndicator } from './UsageIndicator.tsx' import { initDisplayMeta } from './currency.ts' diff --git a/src/client/rounds/observed.ts b/src/client/rounds/observed.ts index 99c08e4..2d01e4a 100644 --- a/src/client/rounds/observed.ts +++ b/src/client/rounds/observed.ts @@ -6,13 +6,13 @@ * (host /usage 折叠)——两条路径保持独立(决策 2)。 * * v1.0.1:观测路径也给出每轮成本——用 /pricing 快照 + 逐轮模型/开始时刻 - * (快照 provenance/requestConfig 与节点 time 推导)按高峰/空闲时段估算 + * (快照 provenance / providerMetadata / requestConfig 与节点 time 推导)按高峰/空闲时段估算 * 双币种成本,使成本视角在没有宿主历史时也能逐轮显示费用。 */ import { useLayoutEffect, useMemo, useRef } from 'react' import type { CostCurrency, TokenUsageBuckets } from '../../pricing/calc.ts' import { costSplitAt, ZERO_BUCKETS } from '../../pricing/calc.ts' -import type { ConversationNode } from '../snapshot.ts' +import { nodeModelName, type ConversationNode } from '../snapshot.ts' import { resolvePricing, type PricingTable } from '../pricing-api.ts' import type { ChartCost, ChartRound } from './types.ts' @@ -25,7 +25,7 @@ interface ObservedState { open: TokenUsageBuckets } -/** 逐轮推导 { 模型, 开始时刻 }:模型取该轮最后一个 assistant 节点的 provenance/requestConfig;时刻取该轮最早节点时间。 */ +/** 逐轮推导 { 模型, 开始时刻 }:模型取该轮 assistant 节点的模型身份(provenance / providerMetadata / requestConfig);时刻取该轮最早节点时间。 */ function turnMeta(nodes: readonly ConversationNode[]): Map { const map = new Map() for (const node of nodes) { @@ -40,8 +40,8 @@ function turnMeta(nodes: readonly ConversationNode[]): Map(selector: (snapshot: ConversationSnapshot) => S) = const NO_CHAT_NODES: ChatNodesHook = () => undefined const EMPTY_NODES: readonly ConversationNode[] = [] -/** 从快照取节点列表:优先 chat.legacy.nodes(rc.6 实际路径),回退顶层 nodes。 */ +/** 从快照取节点列表:优先 chat.legacy.nodes(0.1.x 实际路径),回退顶层 nodes。 */ export function snapshotNodes(snapshot: ConversationSnapshot): readonly ConversationNode[] { const legacy = snapshot.chat?.legacy?.nodes if (Array.isArray(legacy) && legacy.length > 0) return legacy @@ -64,7 +88,8 @@ export function snapshotNodes(snapshot: ConversationSnapshot): readonly Conversa /** * 读会话节点:0.1.2 起 chat 快照是独立标准源(props.useChat.legacy.nodes), - * 不再挂在会话快照上;会话快照旧路径仅作回退(旧平台)。 + * 不再挂在会话快照上;0.2.x 沿用同一路径(ChatSnapshot.legacy.nodes)。 + * 会话快照旧路径仅作回退(旧平台/旧快照)。 */ export function useSessionNodes(useChat: ChatNodesHook | undefined, useSession: SessionHook): readonly ConversationNode[] { // 两个 hook 必须无条件按固定顺序调用:任一条路径少调一次会错位后续 hook 状态 diff --git a/src/usage/rounds.ts b/src/usage/rounds.ts index 140a6c7..a519b8e 100644 --- a/src/usage/rounds.ts +++ b/src/usage/rounds.ts @@ -3,10 +3,16 @@ * * 小接口:`foldRounds(events, resolvePricing?) → { totals, rounds }`。 * 大实现:四桶 token 折叠(与 token-meter 语义一致)+ 轮次耗时 - * (turn/start → turn/end)+ TTFT(turn/start → 首个 usage 样本)+ + * (turn/start → turn/end)+ TTFT(turn/start → 首个输出样本)+ * 输出吞吐(outputTokens / 输出时长)+ 模型归因(request/context 优先、 * request/header 回退、跨轮携带回退)+ 结束原因 + 每轮成本分拆。 * + * 跨版本事件词汇:0.1.x 的流式用量走 `assistant/chunk`,0.2.x 移除了该事件, + * 用量与流内时间线合并进 `assistant/message`(`usage` + `stream`)。这里优先用 + * `stream` 的首/末时间戳计算 TTFT 与 TPS(0.2.x),流时间线缺席时回退到首个 + * usage 样本 / turn 结束时刻(0.1.x 及旧日志)。`user/message.source` 在 0.1.x + * 是字符串,0.2.x 是 `{ kind }` 对象,两者都归一为归因字符串。 + * * 纯函数:不读文件、不发起请求;价格来源经 `resolvePricing` 注入 * (接缝),默认用内置刊例价。测试喂合成事件流即可断言全部字段。 */ @@ -28,10 +34,13 @@ export interface SessionEventLike { provider?: string model?: string contextWindow?: number - source?: string + /** 0.1.x 是来源字符串;0.2.x 是携带 `kind` 的对象。 */ + source?: string | { kind?: string } header?: { config?: { provider?: string; model?: string }; source?: string } chunk?: { type?: string; usage?: unknown } usage?: unknown + /** 0.2.x `assistant/message` 内嵌的流式时间线(绝对时间 + dt 增量编码)。 */ + stream?: unknown } } @@ -69,7 +78,7 @@ export interface UsageRound { outputTps: number | null /** turn/end.reason.kind;未结束为 null。 */ endReason: string | null - /** 用户输入来源归因('human' | 'agent.inject' | 'goal.continuation' 等,缺省为 null)。 */ + /** 用户输入来源归因('human' | 'agent.inject' | 'agent-message' 等;缺省为 null)。 */ userSource: string | null cost: RoundCost } @@ -118,11 +127,128 @@ interface TurnState { end: number | null endReason: string | null model: string | null - /** 首个 usage 样本的时间(TTFT 右端点)。 */ + /** 首个 usage 样本的时间(0.1.x TTFT 右端点)。 */ firstUsageAt: number | null + /** `assistant/message.stream` 时间线的最早输出时间(0.2.x TTFT 右端点,优先)。 */ + firstOutputAt: number | null + /** `assistant/message.stream` 时间线的最晚输出时间。 */ + outputEndAt: number | null + /** 0.2.x:各条 assistant/message|attempt 流的生成时长之和(不含工具执行与后续步骤的预填充)。 */ + streamOutputMs: number buckets: TokenUsageBuckets userSource: string | null } + +/** + * 取 0.2.x `assistant/attempt|message` 流时间线里最后一个 usage 记录 + * (`{ type: 'chunk', chunk: { type: 'usage', usage } }`)。失败/重试的请求只以这种形式留下用量。 + */ +function attemptUsageOf(stream: unknown): unknown { + if (!Array.isArray(stream)) return undefined + let usage: unknown + for (const item of stream) { + if (typeof item !== 'object' || item === null) continue + const chunk = (item as { type?: unknown; chunk?: { type?: unknown; usage?: unknown } }).chunk + if ((item as { type?: unknown }).type === 'chunk' && chunk?.type === 'usage' && chunk.usage !== undefined) usage = chunk.usage + } + return usage +} + +/** 片段是否携带 token:空字符串(打包流常以空片段开头)与缺失值都不算。 */ +function isTokenFragment(fragment: unknown): boolean { + if (typeof fragment === 'string') return fragment !== '' + return fragment !== null && fragment !== undefined +} + +/** + * 原始 delta chunk 是否携带 token(与 DSH 官方 isTokenDelta 一致): + * text / reasoning 需非空文本;tool-call 只要有参数片段或携带工具名即算。 + */ +function isTokenDeltaChunk(chunk: unknown): boolean { + if (typeof chunk !== 'object' || chunk === null) return false + const value = chunk as { type?: unknown; text?: unknown; delta?: unknown; argumentsDelta?: unknown; name?: unknown } + if (typeof value.type !== 'string' || !value.type.endsWith('-delta')) return false + if (value.type === 'tool-call-delta') { + return (typeof value.argumentsDelta === 'string' && value.argumentsDelta !== '') || value.name !== undefined + } + const text = value.text ?? value.delta + return typeof text === 'string' && text !== '' +} + +/** + * 从 0.2.x `assistant/message|attempt.stream` 提取首/末 **token** 时间。 + * + * 时间线条目: + * - `{ type: 'chunk', time, chunk }`:block-start / block-end / usage / finish 等结构边界 + * 不携带 token、不参与取值(否则 TTFT 会落在首个 token 之前);但累加器无法合并的 + * 原始 delta(如 id/name 为空的 tool-call-delta)也以 chunk 形式落盘,按官方 + * isTokenDelta 判定后计入; + * - 打包增量 `{ type: '*-chunks', time0, dt, texts | args }`:第 i 个片段的时间是 + * `time0 + dt[0] + … + dt[i-1]`;只有非空片段才算 token。例外:带 `name` 的 + * `tool-call-chunks` 段每个 delta 都携带工具名,即使 args 片段为空也算 token。 + * @param stream - 未知形状的流时间线(只在数组时解析)。 + * @returns 首个与最后一个 token 的时间;没有 token 时为 null。 + */ +function streamWindow(stream: unknown): { firstAt: number | null; lastAt: number | null } { + if (!Array.isArray(stream)) return { firstAt: null, lastAt: null } + let firstAt: number | null = null + let lastAt: number | null = null + const observe = (time: number): void => { + if (firstAt === null || time < firstAt) firstAt = time + if (lastAt === null || time > lastAt) lastAt = time + } + for (const item of stream) { + if (typeof item !== 'object' || item === null) continue + const record = item as { type?: unknown; time?: unknown; chunk?: unknown; time0?: unknown; dt?: unknown; texts?: unknown; args?: unknown; name?: unknown } + if (record.type === 'chunk') { + if (typeof record.time === 'number' && Number.isFinite(record.time) && isTokenDeltaChunk(record.chunk)) observe(record.time) + continue + } + if (typeof record.time0 !== 'number' || !Number.isFinite(record.time0)) continue + const fragments = Array.isArray(record.texts) ? record.texts : Array.isArray(record.args) ? record.args : null + if (fragments === null) continue + const deltas = Array.isArray(record.dt) ? record.dt : [] + // 累加器只合并同名的 tool-call delta:带 name 的段里每个片段都携带工具名。 + const namedToolCall = typeof record.name === 'string' && record.name !== '' + let time = record.time0 + for (let index = 0; index < fragments.length; index++) { + if (index > 0) { + const delta = deltas[index - 1] + // 增量缺失或非法时无法定位后续片段,停在已知时间为止。 + if (typeof delta !== 'number' || !Number.isFinite(delta) || delta < 0) break + time += delta + } + if (isTokenFragment(fragments[index]) || namedToolCall) observe(time) + } + } + return { firstAt, lastAt } +} + +/** + * 归一 0.2.x `user/message.source.kind`:`user` / `user-*` 是人工输入;目标续跑保持原名; + * 其余(tool-jobs、subagent-settled、agent-message 等)都是运行时注入,统一带上 + * `agent.` 前缀,使面板按 Agent 注入展示,而不是误判为人工。 + */ +function sourceKindOf(kind: string): string { + // user、user-question-reply、user-approval 等都是人的输入 + if (kind === 'user' || kind.startsWith('user-')) return 'human' + if (/agent|goal|continuation/i.test(kind)) return kind + return `agent.${kind}` +} + +/** + * 归一用户输入来源:0.1.x 字符串原样保留;0.2.x 对象按 `kind` 归一(见 sourceKindOf); + * 都缺席时按历史行为回退 'human'。 + */ +function userSourceOf(data: SessionEventLike['data']): string | null { + if (typeof data.source === 'string' && data.source !== '') return data.source + if (typeof data.source === 'object' && data.source !== null) { + const kind = data.source.kind + if (typeof kind === 'string' && kind !== '') return sourceKindOf(kind) + } + return typeof data.header?.source === 'string' && data.header.source !== '' ? data.header.source : 'human' +} + function turnReasonOf(reason: unknown): string | null { if (typeof reason === 'string' && reason !== '') return reason if (typeof reason === 'object' && reason !== null) { @@ -136,8 +262,11 @@ function turnReasonOf(reason: unknown): string | null { * 从会话日志折叠每轮用量(权威基准;client 观测只做实时指示,不合并)。 * * 折叠语义(与 token-meter 一致): - * - 用量来自 `assistant/chunk`(chunk.type === 'usage')与 `assistant/message`; + * - 用量来自 `assistant/chunk`(chunk.type === 'usage')、`assistant/message`,以及 + * 0.2.x 的 `assistant/attempt`(失败/重试请求的已结算用量,内嵌在其 stream 中); * - 同一 (turn, step) 的重复样本替换而不是累加(chunk 早样 → message 终样); + * - `assistant/attempt` 是一次已结算的独立请求:立即入账,不会被同一步骤之后的 + * 重试样本替换(重试边界两侧的尝试分别计费); * - 步骤按日志顺序推进(不变量:后续步骤不会回补更早步骤的用量)。 * * 时序字段只统计有用量样本的轮次;端点缺失(如当前未结束轮)对应字段为 null。 @@ -159,10 +288,15 @@ export function foldRounds(events: readonly SessionEventLike[], resolvePricing?: let currentTurn = -1 let stepBuckets: TokenUsageBuckets | null = null + const stateFor = (turn: number): TurnState => { + const existing = states.get(turn) + if (existing !== undefined) return existing + const created: TurnState = { start: null, end: null, endReason: null, model: null, firstUsageAt: null, firstOutputAt: null, outputEndAt: null, streamOutputMs: 0, buckets: zeroBuckets(), userSource: null } + states.set(turn, created) + return created + } const commitStep = (turn: number, buckets: TokenUsageBuckets): void => { - const state = states.get(turn) ?? { start: null, end: null, endReason: null, model: null, firstUsageAt: null, buckets: zeroBuckets(), userSource: null } - addInto(state.buckets, buckets) - states.set(turn, state) + addInto(stateFor(turn).buckets, buckets) addInto(totals, buckets) } const recordUsageAt = (turn: number, time: number): void => { @@ -188,9 +322,8 @@ export function foldRounds(events: readonly SessionEventLike[], resolvePricing?: case 'turn/start': { const turn = data.turn ?? -1 openTurn = turn - const state = states.get(turn) ?? { start: null, end: null, endReason: null, model: null, firstUsageAt: null, buckets: zeroBuckets(), userSource: null } + const state = stateFor(turn) if (state.start === null) state.start = time - states.set(turn, state) break } case 'turn/end': { @@ -220,25 +353,45 @@ export function foldRounds(events: readonly SessionEventLike[], resolvePricing?: case 'user/message': { const targetTurn = openStepTurn ?? openTurn ?? data.turn if (targetTurn !== undefined && targetTurn !== null) { - const state = states.get(targetTurn) ?? { start: null, end: null, endReason: null, model: null, firstUsageAt: null, buckets: zeroBuckets(), userSource: null } - if (state.userSource === null) { - const src = typeof data.source === 'string' ? data.source : typeof data.header?.source === 'string' ? data.header.source : 'human' - state.userSource = src - } - states.set(targetTurn, state) + const state = stateFor(targetTurn) + if (state.userSource === null) state.userSource = userSourceOf(data) } break } default: { + // 0.2.x:流式时间线内嵌在 assistant/message|attempt.stream 中,先记录首/末输出时间。 + if ((type === 'assistant/message' || type === 'assistant/attempt') && data.turn !== undefined) { + const window = streamWindow(data.stream) + if (window.firstAt !== null || window.lastAt !== null) { + const state = stateFor(data.turn) + if (window.firstAt !== null && (state.firstOutputAt === null || window.firstAt < state.firstOutputAt)) state.firstOutputAt = window.firstAt + if (window.lastAt !== null && (state.outputEndAt === null || window.lastAt > state.outputEndAt)) state.outputEndAt = window.lastAt + if (window.firstAt !== null && window.lastAt !== null) state.streamOutputMs += window.lastAt - window.firstAt + } + } + if (type === 'assistant/attempt') { + const { turn, step } = data + const sample = bucketsOf(attemptUsageOf(data.stream)) + if (sample === null || turn === undefined || step === undefined) continue + // 已结算的失败尝试:替换同步骤未结算的早样,并立即入账;之后的重试另起一段。 + if (stepBuckets !== null && currentKey !== `${turn}:${step}`) commitStep(currentTurn, stepBuckets) + commitStep(turn, sample) + currentKey = '' + currentTurn = -1 + stepBuckets = null + recordUsageAt(turn, time) + continue + } let usage: unknown let turn: number | undefined let step: number | undefined if (type === 'assistant/chunk' && data.chunk?.type === 'usage') { ;({ turn, step } = data) usage = data.chunk.usage - } else if (type === 'assistant/message' && data.usage !== undefined) { + } else if (type === 'assistant/message') { ;({ turn, step } = data) - usage = data.usage + // 与官方 usageOf 一致:data.usage 缺席时回退到流里最后一个 usage 记录。 + usage = data.usage ?? attemptUsageOf(data.stream) } if (usage === undefined || turn === undefined || step === undefined) continue const sample = bucketsOf(usage) @@ -268,11 +421,16 @@ export function foldRounds(events: readonly SessionEventLike[], resolvePricing?: if (!anyTokens(state.buckets)) continue const model = state.model ?? carryModel const durationMs = state.start !== null && state.end !== null ? state.end - state.start : null - const ttftMs = state.start !== null && state.firstUsageAt !== null ? state.firstUsageAt - state.start : null - const outputTps = - state.buckets.outputTokens > 0 && state.end !== null && state.firstUsageAt !== null && state.end > state.firstUsageAt - ? state.buckets.outputTokens / ((state.end - state.firstUsageAt) / 1_000) - : null + // TTFT:0.2.x 用流时间线首个 token;旧日志回退到首个 usage 样本。 + const outputStart = state.firstOutputAt ?? state.firstUsageAt + const ttftMs = state.start !== null && outputStart !== null ? outputStart - state.start : null + // TPS:0.2.x 用各条消息流的生成时长之和(不含工具执行与步骤间等待);整段只有一个 + // token 片段时时长为 0,不报吞吐。旧日志(无流时间线)沿用首个 usage → turn 结束。 + const hasStream = state.firstOutputAt !== null + const outputMs = hasStream + ? state.streamOutputMs + : state.end !== null && state.firstUsageAt !== null ? state.end - state.firstUsageAt : 0 + const outputTps = state.buckets.outputTokens > 0 && outputMs > 0 ? state.buckets.outputTokens / (outputMs / 1_000) : null const resolved = resolver.resolve(model) // 按轮次开始时刻推断高峰/空闲时段计费;时刻缺失(无 turn/start)时按高峰保守估算。 // 双币种各算一份(官方 CNY / USD 刊例价),client 按显示币种选用。 diff --git a/tests/manifest.test.mjs b/tests/manifest.test.mjs index 40ae4ee..9668660 100644 --- a/tests/manifest.test.mjs +++ b/tests/manifest.test.mjs @@ -1,15 +1,52 @@ import assert from 'node:assert/strict' -import { access, readFile } from 'node:fs/promises' +import { access, readdir, readFile } from 'node:fs/promises' import test from 'node:test' const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) +/** + * DSH 客户端包 peer:范围必须同时容纳 0.1.x(如 0.1.0-rc.6)与 0.2.x(如 0.2.1-rc.2)。 + * DSH 门禁用 semver.satisfies(runtime, range, { includePrerelease: true }) 比较运行时版本, + * 因此上界写成 <0.3.0-0:既接受 0.2.x 预发布,又不会把未来 0.3.0-rc.x 提前放行。 + * 不要用 engines.dsh 替代——声明 engines.dsh 会让 DSH 跳过 peerDependencies 范围检查。 + */ +const CLIENT_PEERS = [ + '@deepseek-ai/dsh-client-locale', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-slots', +] + test('package declares an installable DSH web bundle', () => { assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml') assert.equal(pkg.dsh.client.platform, 'web') assert.ok(pkg.keywords.includes('dsh-plugin')) - assert.equal(pkg.peerDependencies['@deepseek-ai/dsh-client-locale'], '>=0.1.0-rc.6 <0.2.0') - assert.equal(pkg.peerDependenciesMeta['@deepseek-ai/dsh-client-locale'].optional, true) +}) + +test('client peer ranges admit DSH 0.1.x and 0.2.x runtimes', () => { + for (const name of CLIENT_PEERS) { + assert.equal(pkg.peerDependencies[name], '>=0.1.0-rc.6 <0.3.0-0') + assert.equal(pkg.peerDependenciesMeta[name]?.optional, true) + } + // DSH 0.2.0 起 @deepseek-ai/dsh-client-runtime 已退役(最后版本 0.1.1-rc.2): + // 它不再有可被检查的运行时含义,客户端类型也不得再 import 它。 + assert.equal(pkg.peerDependencies['@deepseek-ai/dsh-client-runtime'], undefined) + assert.equal(pkg.peerDependenciesMeta['@deepseek-ai/dsh-client-runtime'], undefined) + assert.ok(!pkg.dsh.client.inject.includes('@deepseek-ai/dsh-client-runtime')) + assert.equal(pkg.engines?.dsh, undefined) +}) + +test('built client bundle stays free of the retired client runtime', async () => { + const bundle = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8') + assert.ok(bundle.includes('__ModuleLoader__.load'), 'client bundle must register through the DSH module loader') + assert.ok(!/require\(['"]@deepseek-ai\/dsh-client-runtime/.test(bundle), 'client bundle must not require the retired dsh-client-runtime package') + // 扫描全部发布的 client 声明文件,而不只是入口 + const typesDir = new URL('../lib/types/client/', import.meta.url) + const declarations = (await readdir(typesDir, { recursive: true })).filter((file) => file.endsWith('.d.ts')) + assert.ok(declarations.length > 1, 'client declarations must be emitted') + for (const file of declarations) { + const text = await readFile(new URL(file, typesDir), 'utf8') + assert.ok(!/from ['"]@deepseek-ai\/dsh-client-runtime/.test(text), `${file} must not import the retired dsh-client-runtime package`) + } }) test('all exported runtime and type files exist after build', async () => { diff --git a/tests/merge-regression.test.mjs b/tests/merge-regression.test.mjs index 0693626..6ecdf02 100644 --- a/tests/merge-regression.test.mjs +++ b/tests/merge-regression.test.mjs @@ -19,7 +19,7 @@ async function loadClientModule(path) { return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].contents).toString('base64')}`) } -const [{ useSessionNodes }, { sameUsage, usageDelta, lastRoundModel, sumRoundCosts }] = await Promise.all([ +const [{ useSessionNodes, nodeModelName }, { sameUsage, usageDelta, lastRoundModel, sumRoundCosts }] = await Promise.all([ loadClientModule('../src/client/snapshot.ts'), loadClientModule('../src/client/rounds/summary.ts'), ]) @@ -36,6 +36,17 @@ test('chat nodes take priority in DSH 0.1.2+ while both hooks keep a stable call assert.deepEqual(calls, ['chat', 'session']) }) +test('node model identity reads 0.1.x provenance, 0.2.x providerMetadata, then requestConfig', () => { + const base = { kind: 'assistant', seq: 1, turn: 1 } + // DSH 0.2.x 节点:provenance 已改名为 providerMetadata + assert.equal(nodeModelName({ ...base, providerMetadata: { provider: 'deepseek', model: 'deepseek-v4-pro' }, requestConfig: { model: 'deepseek-flash' } }), 'deepseek-v4-pro') + // DSH 0.1.x 节点:provenance 仍优先 + assert.equal(nodeModelName({ ...base, provenance: { provider: 'deepseek', model: 'deepseek-flash' }, providerMetadata: { provider: 'x', model: 'other' } }), 'deepseek-flash') + // 只有请求配置时回退 requestConfig;空字符串不算模型 + assert.equal(nodeModelName({ ...base, providerMetadata: { provider: 'deepseek', model: '' }, requestConfig: { model: 'deepseek-flash' } }), 'deepseek-flash') + assert.equal(nodeModelName(base), undefined) +}) + test('session nodes remain available when the chat source is absent or empty', () => { const oldNodes = [{ kind: 'assistant', turn: 1, messageId: 'old' }] const useSession = (selector) => selector({ chat: { legacy: { nodes: oldNodes } }, nodes: [] }) diff --git a/tests/rounds.test.mjs b/tests/rounds.test.mjs index 3308751..19fe475 100644 --- a/tests/rounds.test.mjs +++ b/tests/rounds.test.mjs @@ -88,6 +88,155 @@ test('foldRounds computes per-round timing, TTFT, TPS, model attribution and cos assert.equal(r2.ttftMs, 2_000) }) +test('foldRounds uses assistant/message.stream timing on DSH 0.2.x logs (no assistant/chunk)', () => { + const s = stream() + // 0.2.x 会话日志:没有 assistant/chunk,助手消息自带 usage + 流式时间线。 + s.push('turn/start', 1_000, { turn: 1 }) + s.push('step/start', 1_005, { turn: 1, step: 0 }) + s.push('user/message', 1_010, { turn: 1, source: { kind: 'agent-message' } }) + s.push('request/context', 1_020, { provider: 'deepseek', model: 'deepseek-flash' }) + s.push('assistant/message', 9_000, { + turn: 1, + step: 0, + usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10 }, + // 真实 0.2.x 形状:texts 比 dt 多一项(第 i 片段时间 = time0 + Σdt[0..i-1]), + // 打包段常以空片段开头;block-start/usage/finish 等 chunk 边界不携带 token。 + stream: [ + { type: 'chunk', time: 1_500, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }, + { type: 'reasoning-chunks', time0: 2_900, dt: [100, 50], texts: ['', 'Hi', ' there'] }, + { type: 'tool-call-chunks', time0: 4_000, dt: [], id: 'call-1', index: 1, args: ['{"a":1}'] }, + { type: 'chunk', time: 5_000, chunk: { type: 'block-end', index: 1 } }, + { type: 'chunk', time: 5_010, chunk: { type: 'finish' } }, + ], + }) + s.push('turn/end', 9_500, { turn: 1, reason: { kind: 'completed' } }) + + const { totals, rounds } = foldRounds(s.events, flashResolver()) + assert.deepEqual(totals, { uncachedInputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 0 }) + assert.equal(rounds.length, 1) + // TTFT 取首个非空 token 片段 3000(不是 block-start 1500,也不是空片段 2900); + // TPS 窗口止于最后一个 token 片段 4000(不是 block-end / finish)。 + assert.equal(rounds[0].ttftMs, 2_000) + assert.equal(rounds[0].outputTps, 50 / ((4_000 - 3_000) / 1_000)) + assert.equal(rounds[0].durationMs, 8_500) + assert.equal(rounds[0].userSource, 'agent-message') +}) + +test('foldRounds counts DSH 0.2.x failed-attempt usage and keeps retries separate', () => { + const usageChunk = (time, usage) => ({ type: 'chunk', time, chunk: { type: 'usage', usage } }) + // 失败的一轮:只有 assistant/attempt,没有 assistant/message + const failed = stream() + failed.push('turn/start', 1_000, { turn: 1 }) + failed.push('step/start', 1_005, { turn: 1, step: 0 }) + failed.push('assistant/attempt', 4_000, { + turn: 1, + step: 0, + stream: [ + { type: 'text-chunks', time0: 2_000, dt: [], texts: ['partial'] }, + usageChunk(3_900, { inputTokens: 100, outputTokens: 50 }), + ], + }) + failed.push('turn/end', 4_100, { turn: 1, reason: { kind: 'error' } }) + const one = foldRounds(failed.events, flashResolver()) + assert.deepEqual(one.totals, { uncachedInputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }) + assert.equal(one.rounds.length, 1) + assert.equal(one.rounds[0].endReason, 'error') + assert.equal(one.rounds[0].ttftMs, 1_000) + + // 同一步骤先失败后重试成功:两次请求分别计费,重试不替换失败尝试 + const retried = stream() + retried.push('turn/start', 1_000, { turn: 1 }) + retried.push('step/start', 1_005, { turn: 1, step: 0 }) + retried.push('assistant/attempt', 3_000, { turn: 1, step: 0, stream: [usageChunk(2_900, { inputTokens: 100, outputTokens: 10 })] }) + retried.push('assistant/message', 6_000, { turn: 1, step: 0, usage: { inputTokens: 120, outputTokens: 40, cacheReadTokens: 30 } }) + retried.push('turn/end', 6_100, { turn: 1, reason: { kind: 'completed' } }) + const two = foldRounds(retried.events, flashResolver()) + assert.deepEqual(two.totals, { uncachedInputTokens: 220, outputTokens: 50, cacheReadTokens: 30, cacheWriteTokens: 0 }) + assert.deepEqual(two.rounds[0].buckets, two.totals) +}) + +test('foldRounds measures 0.2.x TPS over streaming time only, excluding tool gaps between steps', () => { + const s = stream() + s.push('turn/start', 1_000, { turn: 1 }) + // 步骤 0:2000→3000 流式输出 30 token,随后工具执行到 10000 + s.push('assistant/message', 3_100, { + turn: 1, step: 0, usage: { inputTokens: 10, outputTokens: 30 }, + stream: [{ type: 'text-chunks', time0: 2_000, dt: [1_000], texts: ['a', 'b'] }], + }) + // 步骤 1:10000→11000 流式输出 20 token + s.push('assistant/message', 11_100, { + turn: 1, step: 1, usage: { inputTokens: 10, outputTokens: 20 }, + stream: [{ type: 'text-chunks', time0: 10_000, dt: [1_000], texts: ['c', 'd'] }], + }) + s.push('turn/end', 11_200, { turn: 1, reason: { kind: 'completed' } }) + const { rounds } = foldRounds(s.events, flashResolver()) + assert.equal(rounds[0].ttftMs, 1_000) + // 50 token / (1s + 1s) = 25 tok/s;墙钟窗口(2000→11000)会误报 5.6 tok/s + assert.equal(rounds[0].outputTps, 25) +}) + +test('foldRounds reads 0.2.x raw delta chunks, all named tool-call fragments, and stream-only usage', () => { + const s = stream() + s.push('turn/start', 1_000, { turn: 1 }) + s.push('assistant/message', 9_000, { + turn: 1, + step: 0, + // 没有 data.usage:用量只在流里 + stream: [ + { type: 'chunk', time: 1_500, chunk: { type: 'block-start', index: 0, blockType: 'tool-call' } }, + // 累加器无法合并的原始 tool-call-delta(id 为空)按 chunk 落盘,仍是首个 token + { type: 'chunk', time: 2_000, chunk: { type: 'tool-call-delta', id: '', name: 'run_code', argumentsDelta: '' } }, + // 带 name 的段:每个片段都携带工具名,末尾空参数片段也算 token + { type: 'tool-call-chunks', time0: 3_000, dt: [500, 500], id: 'call-1', index: 0, name: 'run_code', args: ['{"a"', ':1}', ''] }, + { type: 'chunk', time: 4_100, chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 20 } } }, + { type: 'chunk', time: 4_200, chunk: { type: 'finish' } }, + ], + }) + s.push('turn/end', 9_500, { turn: 1, reason: { kind: 'completed' } }) + const { totals, rounds } = foldRounds(s.events, flashResolver()) + assert.deepEqual(totals, { uncachedInputTokens: 10, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0 }) + assert.equal(rounds[0].ttftMs, 1_000) + // 首个 token 2000,最后一个 token 4000(第三个片段) + assert.equal(rounds[0].outputTps, 20 / ((4_000 - 2_000) / 1_000)) +}) + +test('foldRounds normalizes DSH 0.2.x object user/message sources', () => { + const s = stream() + const turn = (n, t, kind) => { + s.push('turn/start', t, { turn: n }) + s.push('user/message', t + 1, { turn: n, source: { kind } }) + // 运行时上下文注入跟在真实来源之后,不应改写本轮来源 + s.push('user/message', t + 2, { turn: n, source: { kind: 'time-context' } }) + s.push('assistant/message', t + 50, { turn: n, step: 0, usage: { inputTokens: 1, outputTokens: 1 } }) + s.push('turn/end', t + 60, { turn: n, reason: { kind: 'completed' } }) + } + turn(1, 1_000, 'user') + turn(2, 2_000, 'tool-jobs') + turn(3, 3_000, 'agent-message') + turn(4, 4_000, 'user-question-reply') + const { rounds } = foldRounds(s.events, flashResolver()) + assert.deepEqual(rounds.map((r) => r.userSource), ['human', 'agent.tool-jobs', 'agent-message', 'human']) +}) + +test('foldRounds treats a name-bearing tool-call delta with empty args as the first token', () => { + const s = stream() + s.push('turn/start', 1_000, { turn: 1 }) + s.push('assistant/message', 9_000, { + turn: 1, + step: 0, + usage: { inputTokens: 10, outputTokens: 20 }, + stream: [ + { type: 'chunk', time: 1_500, chunk: { type: 'block-start', index: 0, blockType: 'tool-call' } }, + { type: 'tool-call-chunks', time0: 3_000, dt: [500, 500], id: 'call-1', index: 0, name: 'run_code', args: ['', '{"a"', ':1}'] }, + ], + }) + s.push('turn/end', 9_500, { turn: 1, reason: { kind: 'completed' } }) + const { rounds } = foldRounds(s.events, flashResolver()) + // 工具名 delta 在 3000 就是首个 token(不等到 3500 的参数片段,也不回退到 9000 的 usage) + assert.equal(rounds[0].ttftMs, 2_000) + assert.equal(rounds[0].outputTps, 20 / ((4_000 - 3_000) / 1_000)) +}) + test('foldRounds bills peak vs off-peak rounds by their start time', () => { const s = stream() // 高峰轮:开始于北京时间 10:00(UTC 02:00) diff --git a/tsconfig.json b/tsconfig.json index 18b9558..3ef36cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,8 +13,7 @@ "types": ["node"], "baseUrl": ".", "paths": { - "@deepseek-ai/cordis": ["./types/cordis.d.ts"], - "@deepseek-ai/dsh-client-runtime/client": ["./types/dsh-client-runtime.d.ts"] + "@deepseek-ai/cordis": ["./types/cordis.d.ts"] } }, "include": ["src", "types"] diff --git a/types/cordis.d.ts b/types/cordis.d.ts index 9e39706..5a72c78 100644 --- a/types/cordis.d.ts +++ b/types/cordis.d.ts @@ -1,9 +1,9 @@ /** - * 最小 cordis Context 声明(自包含 vendored 类型)。 + * 最小 cordis Context 声明(自包含 vendored 类型,仅宿主半区使用)。 * - * 说明:DSH 的客户端包(dsh-client-runtime / dsh-client-ui-slots 等)尚未 - * 对外发布稳定版本(npm 上仅 0.0.1-rc.1),因此本插件按「运行中的 - * dsh@0.1.0-rc.6」实际暴露的 API 形状 vendored 了所需的最小类型。 + * 说明:本插件不把 DSH 内部包作为运行时/类型依赖(它们由运行中的 DSH 提供), + * 因此按 DSH 0.1.x – 0.2.x 实际暴露的宿主 API 形状 vendored 了所需的最小类型; + * 客户端半区的类型是 `src/client/context.ts`(同样自包含,见该文件)。 * 宿主代码只做 type-only import,构建时被擦除,不影响运行。 */ import type { IncomingMessage, ServerResponse } from 'node:http' @@ -18,31 +18,6 @@ export interface WebServer { }): () => void } -/** ui-slots 槽位注册选项(本插件用到的子集)。 */ -export interface SlotRegisterOptions { - name: string - id?: string - order?: number - priority?: number - registrant?: string - locale?: string -} - -/** ui-slots 的 SlotRegistry 服务(register 返回卸载函数)。 */ -export interface SlotRegistry { - register(options: SlotRegisterOptions, component: unknown): () => void - /** 等待某个槽位声明出现后注册(声明生命周期内生效,随调用方 fiber 卸载)。 */ - inject(key: string, callback: () => (() => void) | void): () => void -} - -/** dsh-client-locale 的 locale 服务(本插件用到的面:订阅活动语言)。 */ -export interface LocaleService { - /** 当前语言快照(active = 'zh' | 'en';语言切换或字典注册都会通知订阅者)。 */ - getLocale(): { active: string; locales: readonly { id: string }[]; revision: number } - /** 订阅快照变化(监听器无参调用,自行重读 getLocale;返回退订函数)。 */ - subscribe(listener: () => void): () => void -} - /** 会话日志事件的最小形状(assistant/chunk 与 assistant/message 携带 turn/step/usage)。 */ export interface SessionEventLike { type: string @@ -62,7 +37,7 @@ export interface SessionHandle { id: string /** 旧版 dsh-session(< 0.1.2-rc.1)的公开事件数组;新版已移除,改为 snapshotEvents()。 */ events?: readonly SessionEventLike[] - /** 新版 dsh-session(>= 0.1.2-rc.1)的公开事件快照方法。 */ + /** 新版 dsh-session(>= 0.1.2-rc.1,0.2.x 同样保留)的公开事件快照方法。 */ snapshotEvents?: () => readonly SessionEventLike[] } export interface SessionStoreService { @@ -78,16 +53,14 @@ export interface CredentialsService { resolve(ref: string): Promise<{ value: string; source?: string } | undefined> } -/** cordis Context(本插件用到的面:effect + get + webServer + slots + sessions + locale)。 */ +/** cordis Context(本插件宿主半区用到的面:effect + get + webServer + sessions)。 */ export interface Context { /** 注册一个资源释放器,插件卸载时执行。 */ effect(disposer: () => (() => void) | void, label?: string): void /** 读取服务(反射层):不要求 inject 声明;服务不存在返回 undefined。 */ get(name: string, strict?: boolean): unknown webServer: WebServer - slots: SlotRegistry sessions: SessionStoreService - locale: LocaleService /** 可选服务(如 credentials)一律经 ctx.get 读取——未 inject 的服务属性访问会抛错。 */ [key: string]: unknown } diff --git a/types/dsh-client-runtime.d.ts b/types/dsh-client-runtime.d.ts deleted file mode 100644 index 7f9cf86..0000000 --- a/types/dsh-client-runtime.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 最小 @deepseek-ai/dsh-client-runtime/client 声明(vendored,见 cordis.d.ts 说明)。 - * 仅覆盖本插件用到的类型:ClientContext / SessionId / 投影钩子。 - */ -import type { Context } from '@deepseek-ai/cordis' - -/** 会话 id(wire 上即字符串)。 */ -export type SessionId = string - -/** 会话投影表(token-meter 注册的两个 key;client 端 wire payload 即四桶合计)。 */ -export interface SessionProjectionMap { - tokenUsage: { - uncachedInputTokens: number - outputTokens: number - cacheReadTokens: number - cacheWriteTokens: number - } - contextPressure: { - pressureTokens?: number - projectedTokens?: number - contextWindow?: number - } -} - -/** framework 第五钩子位:按键读投影(undefined = 能力缺失/无值)。 */ -export type UseProjection = { - (key: K): SessionProjectionMap[K] | undefined - ( - key: K, - selector: (value: SessionProjectionMap[K] | undefined) => S, - eq?: (a: S, b: S) => boolean, - ): S -} - -/** 会话快照选择器钩子。 */ -export type SnapshotSelectorHook = ( - selector: (state: T) => S, - equalityFn?: (a: S, b: S) => boolean, -) => S - -/** 浏览器端 cordis Context(含 slots 合并)。 */ -export type ClientContext = Context