@jetecho/dsh-csv-and-image-preview 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Agent.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agent.md — dsh-csv-and-image-preview 行为契约
2
2
 
3
- 这份文档定义当你(agent)使用 `dsh-csv-and-image-preview` 时应遵守的**预览式工作流**。它是给 agent 读的规则,搭配仓库里的独立 agent preset(`image-preview`)一起使用,把规则写进会话 persona。
3
+ 这份文档定义当你(agent)使用 `dsh-csv-and-image-preview` 时应遵守的**预览式工作流**。可将这些规则加入会话指令。
4
4
 
5
5
  ## 核心原则:先预览,后落地
6
6
 
@@ -18,7 +18,8 @@
18
18
  ## 不要做的事
19
19
 
20
20
  - 不要在没预览的情况下,直接覆盖或新增一个图片 / SVG / CSV 文件。
21
- - 不要把图片字节或 CSV 全文/大段表格数据塞进回复文本或模型上下文 —— 那是 meta 的活,你只需拿到紧凑摘要。
21
+ - 不要把图片字节或 CSV 全文/大段表格数据塞进回复文本或工具输出 —— 插件通过独立快照通道展示,你只需拿到紧凑摘要。
22
+ - 返回“预览数据已生成”只表示快照保存成功。如果用户报告没有显示,按失败信息排查或重新预览,不要坚持声称已经显示。
22
23
  - 不要用 markdown `![alt](data:...)` 或 ```` ```dsh-ui ```` 的 `image` 组件来"显示图片"—— 聊天渲染器会拦截,必须走 `preview_image`;同理 CSV 必须走 `preview_csv`,不要用整段代码块充当"预览"。
23
24
 
24
25
  ## 交互话术
@@ -34,7 +35,10 @@
34
35
  | `preview_image` 工具 | 读取文件 / 内联 SVG → data-URI → 浏览器 `<img>` 渲染 |
35
36
  | `preview_csv` 工具 | 读取 CSV/TSV / 内联文本 → 有界解析(默认前 50 行,上限 500,列/单元格截断)→ 浏览器 `<table>` 渲染,控件限高局部滚动,表头可点击排序 |
36
37
  | 键控 toolview | `tool.call.toolview` key=`preview_image` / `preview_csv`,渲染原生 DOM |
37
- | meta 通道 | 图片字节与表格数据只走 `presentationMeta` 进浏览器,不进模型上下文 |
38
+ | 聊天正文预览 | 回答结束后在正文末尾直接显示已生成的预览,不需要用户点击侧栏;刷新历史时重放调用并读取快照 |
39
+ | 快照通道 | 按会话 ID 和调用 ID 保存快照,经 DSH 认证的 RPC 通道给浏览器读取;直接调用和 `run_code` 均支持;旧 meta 仍兼容 |
40
+
41
+ 预览本身会在插件数据目录保存快照,不修改目标文件。新建目标文件仍需遵循用户的预览确认约定。
38
42
 
39
43
  ## 为什么
40
44
 
package/README.en.md CHANGED
@@ -2,78 +2,80 @@
2
2
 
3
3
  English | [中文](README.md)
4
4
 
5
- Render images, SVG, and CSV **for real** in the DeepSeek Harness (dsh) chat images and SVG as a native browser `<img>`, CSV/TSV tables as a native `<table>` (sticky header, height-capped inner scrolling) — bypassing the chat renderer's markdown / genui filter, and paired with a **preview-first workflow**: generate or modify an image, SVG, or CSV, render it to you first, and only apply the real change once you confirm.
5
+ Preview images, SVG and CSV/TSV in DeepSeek Harness chat using native `<img>` and `<table>` elements. Tables have sticky headers, 320px internal scrolling and sortable columns.
6
6
 
7
- ## Install
7
+ ## 0.1.3: dedicated inline preview rows
8
8
 
9
- Standard Profile Bundle install (recommended):
9
+ Version 0.1.3 registers dedicated keyed Chat nodes after turn/end, outside the collapsed process interval, preserving DSH's existing file cards. Native and nested preview calls and legacy metadata are replayed. Refreshing or reopening history reloads saved snapshots. Files presented only through DSH's `present` tool are outside this feature.
10
10
 
11
- ```sh
12
- dsh plugin --profile web add @jetecho/dsh-csv-and-image-preview
13
- ```
11
+ Run `npm test`; set `DSH_UPSTREAM` to a DSH source checkout to include the real conversation assembler integration. `npm run preview:test` provides a browser fixture with a completion button and refresh verification.
14
12
 
15
- Or install the development copy from a local directory:
13
+ ## Install and upgrade
14
+
15
+ Version 0.1.3 is tested with **DSH 0.1.5-rc.1** and requires its Connection RPC interface or a compatible newer version.
16
16
 
17
17
  ```sh
18
- dsh plugin --profile web add ../dsh-csv-and-image-preview
18
+ # Install the published version
19
+ dsh plugin --profile web add @jetecho/dsh-csv-and-image-preview
20
+
21
+ # Build and install this checkout without publishing to npm
22
+ npm ci
23
+ npm test
24
+ npm pack
25
+ dsh plugin --profile web add ./jetecho-dsh-csv-and-image-preview-0.1.3.tgz
19
26
  ```
20
27
 
21
- After installing, restart `dsh web` so the new bundle joins the runtime. Legacy fallback for older versions: copy the package into the profile's `node_modules` and insert the `- insert: [...]` row in `cordis.patch.yml` — keep this for old-version compatibility only.
28
+ Restart `dsh web` and refresh the browser after installation. Package name, bundle row ID and tool names are unchanged.
22
29
 
23
- ## What it does
30
+ ## Compatibility fixes
24
31
 
25
- 1. **Real image rendering.** The host registers a `preview_image` tool: it reads a file path (or inline SVG), infers the MIME, base64-encodes it into a data-URI, and projects it through the tool result's `presentationMeta`. The browser-side keyed toolview (`tool.call.toolview`, key `preview_image`) reads that meta and renders a native `<img>`. The image bytes travel only through meta into the browser **never into the model context**.
26
- 2. **Preview-first workflow.** After calling `preview_image`, the agent receives a compact summary (SVG/bitmap, dimensions, byte count) while the actual image is shown in chat; the agent then **waits for your confirmation** before writing files or applying changes.
27
- 3. **Preview any resource.** Pass a local path to preview the matching image file, or pass inline SVG text to render without writing to disk.
28
- 4. **CSV/TSV table preview.** The host registers a `preview_csv` tool: it reads a CSV/TSV file (or inline text), sniffs the delimiter (`,` `;` Tab `|`, or set `delimiter` explicitly), parses it with hard bounds, and projects the table through meta. The browser-side keyed toolview (key `preview_csv`) renders a native `<table>`. The widget is **capped at 320px with inner scrolling**; only the first 50 rows are delivered by default (`maxRows` adjustable up to 500), and overly wide columns / long cells are truncated too — the model receives only a row/column summary; table data never enters the model context. Click a header to **sort** (numeric vs. Chinese-pinyin collation auto-detected, empty values sink, asc → desc → off). Overlong cells get an ellipsis with full text on hover.
32
+ The plugin registers only the exact `/api/csv-and-image-preview/get` route, preserving the shared `/api` interceptor owned by DSH's Gateway so history and plugin lists load normally. Upgrade, restart DSH and refresh the page; the fix does not modify session files.
29
33
 
30
- ## Usage examples
34
+ - Explicit client dependencies on `slots` and `connection`, and host dependencies on `tools` and `connection`. Late service startup parks the plugin; unloading cleans up registrations.
35
+ - **Native calls and `run_code` child calls both work.** Current DSH computes `presentationMeta` only for top-level calls. New previews use a separate snapshot channel.
36
+ - Inputs are read and parsed once, then saved before success is returned. Both the model and code program receive only a string summary, with no additional image bytes or table body.
37
+ - Separate generating, loading, tool failure, missing snapshot and image decode failure states. Includes retry, reconnection refresh and cancellation of stale requests when navigating.
38
+ - Legacy `block.meta.src` and `block.meta.rows` still render. Old nested calls that never persisted presentation data must be previewed again.
31
39
 
32
- ```markdown
33
- <!-- Preview a file on disk -->
34
- I'll show you the preview first.
35
- [call preview_image path=E:\DSHConfig\logo-a.svg label=ALPHA]
36
- ```
40
+ ## Usage
37
41
 
38
- ```markdown
39
- <!-- Preview inline SVG (not yet on disk) -->
40
- [call preview_image content=<svg ...> mime=image/svg+xml label=sketch]
42
+ ```text
43
+ preview_image {"path":"D:/assets/logo.svg","label":"Logo preview"}
44
+ preview_csv {"path":"D:/data/sales.csv","label":"Sales","maxRows":100}
45
+ preview_csv {"content":"Name,Score\nAlice,90\nBob,85"}
41
46
  ```
42
47
 
43
- ```markdown
44
- <!-- Preview a CSV file (truncated to the first 100 rows) -->
45
- [call preview_csv path=E:\DSHConfig\data.csv label=sales maxRows=100]
46
- ```
48
+ Inline SVG text can be supplied as `content`. Relative paths resolve against the session working directory. `path` takes precedence over `content`.
47
49
 
48
- The agent sees something like `已渲染预览「ALPHA」:SVG 矢量图(1.3 KB).` while the picture appears in the tool card; for CSV it reads `已渲染 CSV 预览「sales」:CSV 表格 1234 × 5 列,分隔符 ",";显示前 100 行.` while the table appears in the tool card.
50
+ Images support SVG, PNG, JPEG, GIF, WebP, BMP, ICO and AVIF. Inline non-SVG content requires standard base64 and a `mime` value. Image and CSV inputs are capped at 8 MiB; the inline base64 limit applies to encoded input size.
49
51
 
50
- ## Convention: preview first
52
+ CSV files require UTF-8 (BOM supported); other encodings produce an explicit conversion message. Delimiter detection supports comma, semicolon, Tab and pipe, with a `delimiter` override; `.tsv` defaults to Tab. Defaults: 50 rows; limits: 500 rows including the header, 40 columns and 200 characters plus an ellipsis per cell. Hover shows retained text only; discarded text is not recoverable. Headers cycle ascending, descending and original order, with empty values last.
51
53
 
52
- This plugin treats "preview, then apply" as the default behavior:
54
+ ## Storage and replay
53
55
 
54
- - When generating / modifying an image, SVG, or CSV, **preview it first** with `preview_image` / `preview_csv`; do not write the real file yet.
55
- - Only after the user confirms (`确认` / `可以` / `就这样`) do you actually write / mutate.
56
- - If the user rejects it, redo per feedback and preview again.
57
- - To preview any resource, pass `path` directly.
56
+ Snapshots live in `$DSH_HOME/storages/csv-and-image-preview/`, defaulting to `~/.dsh/storages/csv-and-image-preview/`. Records are keyed by session ID, call ID and tool name using hashed filenames. Reads use DSH Connection's authenticated `/api` RPC channel and verify record identity.
58
57
 
59
- To enforce this convention, pair it with the bundled agent preset (see `Agent.md`), which writes these rules into the session persona.
58
+ The same session can replay the original snapshot after refreshing, restarting DSH or changing/deleting the source file. Back up this directory alongside session data. Snapshots are not automatically pruned; deleting them removes new-format historical previews. Copying/forking into another session does not automatically migrate snapshots.
60
59
 
61
- ## Layout
60
+ The returned “preview data generated” summary acknowledges persistence, not successful browser decoding. Tool errors are reported as real failures.
62
61
 
63
- ```
64
- lib/index.js host: registers the preview_image / preview_csv tools; reads files and projects data-URIs / table meta
65
- lib/client.js browser bundle: registers the keyed toolviews; renders a native <img> / <table>
66
- cordis.patch.yml profile insert line
67
- ```
62
+ ## Preview-first convention
63
+
64
+ Preview generated or modified images/SVG/CSV before changing target files, then wait for user approval. Previewing itself writes a snapshot in the plugin data directory. This is an agent convention, not an enforcement mechanism against other write tools. Add [Agent.md](Agent.md) to your agent instructions if desired.
68
65
 
69
66
  ## Development
70
67
 
71
68
  ```sh
72
- pnpm install
73
- pnpm build # no-op: lib/ ships prebuilt, no transpile
74
- pnpm prepack # verify the packed tarball contains lib/
69
+ npm ci
70
+ npm test # Cordis / DSH dispatch, persistence and React regression tests
71
+ npm run preview:test # Local browser verification page; prints its URL
72
+ npm run build # lib/ is publishable JavaScript
73
+ npm pack --dry-run
75
74
  ```
76
75
 
77
- ## License
76
+ - `lib/index.js`: tool definitions and input parsing.
77
+ - `lib/store.js`: atomic snapshots, bounded reads and authenticated channel handler.
78
+ - `lib/client.js`: toolviews, RPC reads, status/retry and legacy metadata support.
79
+ - `cordis.patch.yml`: Profile Bundle insertion.
78
80
 
79
- MIT © [jetecho](./LICENSE). See [LICENSE](./LICENSE).
81
+ MIT © [jetecho](LICENSE).
package/README.md CHANGED
@@ -2,78 +2,84 @@
2
2
 
3
3
  [English](README.en.md) | 中文
4
4
 
5
- 在 DeepSeek Harness (dsh) 聊天里**真正把图片 / SVG / CSV 显示出来**的插件。图片与 SVG 以浏览器原生 `<img>` 渲染,CSV/TSV 表格以原生 `<table>` 渲染(粘性表头、控件限高局部滚动),均绕过聊天渲染器对 markdown / genui 的过滤;同时内置**预览式工作流**:生成或修改图片、SVG 或 CSV 时,先把结果渲染给你看,等你确认后再落地真正的改动。
5
+ 在 DeepSeek Harness 聊天中预览图片、SVG CSV/TSV。图片使用原生 `<img>`,表格使用原生 `<table>`,支持粘性表头、320px 内部滚动和点击排序。
6
6
 
7
- ## 安装
7
+ ## 安装与升级
8
8
 
9
- 标准 Profile Bundle 安装(推荐):
9
+ 0.1.3 针对 DSH **0.1.5-rc.1** 验证,使用新版 Connection RPC 接口。请使用该版本或兼容接口的更新版。
10
10
 
11
11
  ```sh
12
+ # 安装已发布版本
12
13
  dsh plugin --profile web add @jetecho/dsh-csv-and-image-preview
14
+
15
+ # 从当前仓库打包并安装修复版(无需发布 npm)
16
+ npm ci
17
+ npm test
18
+ npm pack
19
+ dsh plugin --profile web add ./jetecho-dsh-csv-and-image-preview-0.1.3.tgz
13
20
  ```
14
21
 
15
- 或从本地目录安装开发版:
22
+ 安装后重启 `dsh web` 并刷新浏览器。包名、bundle 行 ID 和工具名均保持不变。
16
23
 
17
- ```sh
18
- dsh plugin --profile web add ../dsh-csv-and-image-preview
19
- ```
24
+ ## 0.1.3:修正正文预览的实际挂载
20
25
 
21
- 安装后需重启 `dsh web` 让新 bundle 进入运行时。更早版本兼容做法是手动把包复制进 profile 的 `node_modules` 并在 `cordis.patch.yml` 插入 `- insert: [...]`,仅作旧版本兜底。
26
+ 0.1.3 使用独立的 `conversation.chat.node` 键控内容行,在 `turn/end` 之后显示预览,避开执行过程的折叠区间。原有文件卡片继续由 DSH 渲染。
22
27
 
23
- ## 它做什么
28
+ 图片、SVG、CSV 支持直接调用、`run_code` 嵌套调用和旧版 meta。刷新或重新打开会话后重放已保存的预览;仅调用 DSH `present` 而没有调用本插件的文件不在此功能范围内。
24
29
 
25
- 1. **真渲染图片**。宿主注册 `preview_image` 工具:读取文件路径(或内联 SVG),推断 MIME、base64 编码成 data-URI,通过工具结果的 `presentationMeta` 投递;浏览器端键控 toolview(`tool.call.toolview` key=`preview_image`)读取 meta 并渲染原生 `<img>`。图片字节只走 meta 进浏览器,**不进入模型上下文**。
26
- 2. **预览式工作流**。调用 `preview_image` 后,脚本拿到的是紧凑摘要(SVG/位图、尺寸、字节数),真正的画面直接显示在聊天里;脚本会「等待用户确认」,等你点头后才写入文件/执行改动。
27
- 3. **预览任意资源**。给一个本地路径即可预览对应图片文件;也能传入内联 SVG 文本直接渲染,无需落盘。
28
- 4. **CSV/TSV 表格预览**。宿主注册 `preview_csv` 工具:读取 CSV/TSV 文件(或内联文本),自动嗅探分隔符(`,` `;` Tab `|`,可用 `delimiter` 指定),有界解析后把表格投递到 meta;浏览器端键控 toolview(key=`preview_csv`)渲染原生 `<table>`。表格控件**限高 320px 局部滚动**,默认只投递前 50 行(`maxRows` 可调,上限 500),超宽列数与超长单元格也会截断——模型只拿到行列摘要,表格数据不进模型上下文。表头**点击排序**(数值/中文拼音自适应,空值沉底,升→降→取消循环),单元格超长自动省略号、悬停看全文。
30
+ 验证:`npm test` 使用真实 SlotCore 校验注册。设置 `DSH_UPSTREAM` DSH 源码目录后,可额外使用真实 ConversationNodeAssembler ChatSnapshotBuilder 检查增量事件与历史回放。`npm run preview:test` 提供浏览器验收页;同时设置 `DSH_SESSION` 为本机会话文件时,只读回放该会话及已有快照,不修改原会话文件。
29
31
 
30
- ## 用法示例
32
+ ## 兼容性修复
31
33
 
32
- ```markdown
33
- <!-- 预览磁盘上的 logo -->
34
- 我先给你看预览。
35
- [调用 preview_image path=E:\DSHConfig\logo-a.svg label=ALPHA]
36
- ```
34
+ 预览只注册 `/api/csv-and-image-preview/get` 精确路由,避免占用由 DSH 系统网关独享的 `/api` 拦截器,保证历史列表和插件列表正常加载。升级后重启 DSH 并刷新页面即可重新加载,修复不修改会话文件。
37
35
 
38
- ```markdown
39
- <!-- 预览内联 SVG(未落盘) -->
40
- [调用 preview_image content=<svg ...> mime=image/svg+xml label=草图]
41
- ```
36
+ - 前端声明 `slots`、`connection` 服务依赖,后端声明 `tools`、`connection`;服务稍晚就绪时会等待,卸载时清理注册。
37
+ - **直接调用和 `run_code` 嵌套调用均可预览。** 新版 DSH 不为嵌套调用生成 `presentationMeta`,因此本插件改用独立快照通道。
38
+ - 工具只读取、解析一次输入,在返回成功之前保存快照。模型和 `run_code` 程序收到的仍是字符串摘要,不额外包含图片字节或表格正文。
39
+ - 前端区分生成中、加载中、执行失败、快照缺失和图片解码失败,支持重试、连接恢复后重新读取,以及切换会话时取消过期请求。
40
+ - 旧会话已有的 `block.meta.src` / `block.meta.rows` 继续显示。旧版嵌套调用如果从未保存展示数据,需重新调用预览工具。
41
+
42
+ ## 使用
42
43
 
43
- ```markdown
44
- <!-- 预览 CSV 文件(截断到前 100 行) -->
45
- [调用 preview_csv path=E:\DSHConfig\data.csv label=销售数据 maxRows=100]
44
+ 让 agent 调用工具,例如:
45
+
46
+ ```text
47
+ preview_image {"path":"D:/assets/logo.svg","label":"Logo 预览"}
48
+ preview_csv {"path":"D:/data/sales.csv","label":"销售数据","maxRows":100}
49
+ preview_csv {"content":"姓名,分数\n张三,90\n李四,85"}
46
50
  ```
47
51
 
48
- 脚本会读到类似 `已渲染预览「ALPHA」:SVG 矢量图(1.3 KB)。` 的返回,图片则显示在工具卡片里;CSV 则是 `已渲染 CSV 预览「销售数据」:CSV 表格 1234 行 × 5 列,分隔符 ",";显示前 100 行。`,表格显示在工具卡片里。
52
+ 内联 SVG 可直接传入 `content`。相对路径以当前会话工作目录为基准。`path` `content` 同时存在时优先读取 `path`。
49
53
 
50
- ## 约定:预览优先
54
+ 图片支持 SVG、PNG、JPEG、GIF、WebP、BMP、ICO、AVIF。内联非 SVG 内容使用标准 base64,同时指定 `mime`。图片和 CSV 输入均限制为 8 MiB;内联 base64 限制按编码后的输入大小计算。
51
55
 
52
- 本插件把「先预览、后落地」作为默认约定:
56
+ CSV 文件必须为 UTF-8,支持 UTF-8 BOM;其他编码会明确提示转换,而不是输出乱码。自动识别逗号、分号、Tab、竖线,或用 `delimiter` 指定;`.tsv` 默认 Tab。默认显示前 50 行,最多 500 行(均含表头)、40 列,每个单元格最多保存 200 字符加省略号。悬停显示已保存的单元格内容,截断部分无法还原。点击表头循环升序、降序、原始顺序,空值始终沉底。
53
57
 
54
- - 生成 / 修改图片、SVG 或 CSV 时,**先** `preview_image` / `preview_csv` 渲染,不做真实写入;
55
- - 用户确认(`确认` / `可以` / `就这样`)后,才真正写文件 / 执行改动;
56
- - 用户否决时,按反馈重做并再次预览;
57
- - 需要预览任意资源时,直接传 `path`。
58
+ ## 数据与历史预览
58
59
 
59
- 要强制遵守这一约定,可以配合附带的 agent preset(见 `Agent.md`),它会把这套规则写进会话 persona。
60
+ 预览快照保存在 `$DSH_HOME/storages/csv-and-image-preview/`;未设置 `DSH_HOME` 时使用 `~/.dsh/storages/csv-and-image-preview/`。每条记录按会话 ID、调用 ID 和工具名定位,路径采用哈希文件名。通过 DSH Connection 的已认证 `/api` RPC 通道读取,并核对记录身份。
60
61
 
61
- ## 组成部分
62
+ 刷新页面、重启 DSH 或修改/删除原文件后,同一会话仍显示生成时的快照。备份会话时应同时备份此目录。当前不自动清理快照;删除目录会让新版历史预览失效。复制/分叉成新会话时不自动迁移原会话的快照。
62
63
 
63
- ```
64
- lib/index.js 宿主:注册 preview_image / preview_csv 工具,读取文件并投递 data-URI / 表格 meta
65
- lib/client.js 浏览器 bundle:注册键控 toolview,渲染原生 <img> / <table>
66
- cordis.patch.yml profile 插入行
67
- ```
64
+ 调用返回 `预览数据已生成…` 表示快照保存成功,不代表浏览器已解码显示。工具执行错误作为真正的失败交给 DSH,避免误报成功。
65
+
66
+ ## 预览优先约定
67
+
68
+ 生成或修改图片、SVG、CSV 时,先预览,用户认可后再修改目标文件;预览快照本身会写入插件数据目录。此约定由 agent 遵守,插件不阻止其他写文件工具。可将 [Agent.md](Agent.md) 的说明加入 agent 指令。
68
69
 
69
70
  ## 开发
70
71
 
71
72
  ```sh
72
- pnpm install
73
- pnpm build # no-op:lib/ 为成品,无需转译
74
- pnpm prepack # 校验打包内容含 lib/
73
+ npm ci
74
+ npm test # Cordis / DSH 工具调度、持久化、React 组件回归
75
+ npm run preview:test # 本地浏览器验收页,终端显示地址
76
+ npm run build # lib/ 为可直接发布的 JavaScript
77
+ npm pack --dry-run # 检查发布文件
75
78
  ```
76
79
 
77
- ## License
80
+ - `lib/index.js`:工具定义、输入读取与解析。
81
+ - `lib/store.js`:原子快照写入、读取边界、认证通道处理函数。
82
+ - `lib/client.js`:工具视图、RPC 读取、状态与重试、旧 meta 兼容。
83
+ - `cordis.patch.yml`:标准 Profile Bundle 插入行。
78
84
 
79
- MIT © [jetecho](./LICENSE),详见 [LICENSE](./LICENSE)。
85
+ MIT © [jetecho](LICENSE).
package/lib/client.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * dsh-csv-and-image-preview 浏览器端 bundle(单文件,经 __ModuleLoader__ 加载)。
3
3
  *
4
4
  * 为两个工具注册键控 toolview(`tool.call.toolview`, key = 工具名):
5
- * - `preview_image`:从结果节点 meta 读取 { src, mime, label },渲染浏览器
5
+ * - `preview_image`:通过会话/调用 ID 读取快照(兼容旧 meta),渲染浏览器
6
6
  * 原生 <img>;
7
- * - `preview_csv`:从结果节点 meta 读取 { label, delimiter, rows, ... },
7
+ * - `preview_csv`:读取表格快照 { label, delimiter, rows, ... },
8
8
  * 渲染原生 <table>(粘性表头、控件限高 320px 局部滚动、表头点击排序)。
9
9
  *
10
10
  * 这样图片/表格都不经过聊天渲染器的 markdown/genui 白名单,直接以真实 DOM
@@ -30,6 +30,7 @@ window.__ModuleLoader__.load({
30
30
  '.dip-img{display:block;max-width:100%;height:auto;border-radius:8px}',
31
31
  '.dip-img.svg{background:#fff}',
32
32
  '.dip-missing{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
33
+ '.dip-error{font-size:12px;color:var(--dsw-alias-state-error-primary,#c33)}',
33
34
  '/* preview_csv 表格:控件限高,内部滚动,不撑爆聊天卡片 */',
34
35
  '.dip-table-wrap{max-height:320px;max-width:100%;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:8px}',
35
36
  '.dip-table{border-collapse:separate;border-spacing:0;font-size:12px;line-height:20px;white-space:nowrap}',
@@ -57,19 +58,71 @@ window.__ModuleLoader__.load({
57
58
  return block !== null && typeof block === 'object' && 'meta' in block ? block.meta : undefined
58
59
  }
59
60
 
61
+ function legacyMeta(props) {
62
+ const meta = metaOf(props)
63
+ if (!meta || typeof meta !== 'object') return null
64
+ if (props.toolName === 'preview_image' && typeof meta.src === 'string') return meta
65
+ if (props.toolName === 'preview_csv' && Array.isArray(meta.rows)) return meta
66
+ return null
67
+ }
68
+
69
+ /** Cancel stale reads on navigation; re-read after connection recovery or retry. */
70
+ function usePreview(props) {
71
+ const block = props.block
72
+ const legacy = legacyMeta(props)
73
+ const settled = block?.kind === 'tool-result'
74
+ const text = (block?.content || []).filter(c => c.type === 'text').map(c => c.text).join('\n')
75
+ const failed = block?.isError || /^preview_(?:image|csv) 失败:/.test(text)
76
+ const key = JSON.stringify([props.sessionId, props.callId, props.toolName])
77
+ const [attempt, setAttempt] = React.useState(0)
78
+ const [state, setState] = React.useState(null)
79
+ const generation = React.useSyncExternalStore(props.subscribeConnection, props.getConnection, props.getConnection)
80
+ React.useEffect(() => {
81
+ if (legacy || !settled || failed) return
82
+ const controller = new AbortController()
83
+ let active = true
84
+ setState({ key, loading: true })
85
+ props.loadPreview({ sessionId: props.sessionId, callId: props.callId, toolName: props.toolName }, controller.signal)
86
+ .then(meta => { if (active) setState({ key, meta }) })
87
+ .catch(error => { if (active) setState({ key, error: error.message || '加载预览失败。' }) })
88
+ return () => { active = false; controller.abort() }
89
+ }, [key, settled, failed, legacy, attempt, generation, props.loadPreview])
90
+ if (failed) return { error: text || '预览执行失败。' }
91
+ if (legacy) return { meta: legacy }
92
+ if (!settled) return { loading: true, message: '正在生成预览…' }
93
+ const current = state?.key === key ? state : { loading: true }
94
+ return { ...current, retry: () => setAttempt(n => n + 1) }
95
+ }
96
+
97
+ function PreviewStatus({ state }) {
98
+ return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
99
+ React.createElement('span', { className: state.error ? 'dip-error' : 'dip-missing', role: state.error ? 'alert' : 'status' },
100
+ state.error || state.message || '正在加载预览…'),
101
+ state.error && state.retry ? React.createElement('button', { type: 'button', onClick: state.retry }, '重试') : null)
102
+ }
103
+
104
+ function validImage(meta) {
105
+ return meta && typeof meta.src === 'string'
106
+ && /^data:image\/(?:svg\+xml|png|jpeg|gif|webp|bmp|x-icon|avif);base64,/.test(meta.src)
107
+ }
108
+
60
109
  /**
61
110
  * 键控 toolview(preview_image):从 meta 读取 { src, mime, label } 渲染
62
111
  * 原生 <img>。
63
112
  */
64
113
  function PreviewToolView(props) {
65
- const meta = metaOf(props)
114
+ const state = usePreview(props)
115
+ const meta = state.meta
116
+ const [failedSrc, setFailedSrc] = React.useState(null)
117
+ React.useEffect(() => { setFailedSrc(null) }, [props.sessionId, props.callId])
66
118
  const label = typeof meta === 'object' && meta !== null && typeof meta.label === 'string' ? meta.label : '预览'
67
119
  const src = typeof meta === 'object' && meta !== null && typeof meta.src === 'string' ? meta.src : null
68
120
  const isSvg = typeof meta === 'object' && meta !== null && meta.mime === 'image/svg+xml'
69
- if (src === null) {
70
- return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
71
- React.createElement('span', { className: 'dip-missing' }, '图片预览缺少数据(meta 缺失)'))
72
- }
121
+ if (!meta) return React.createElement(PreviewStatus, { state })
122
+ if (!validImage(meta)) return React.createElement(PreviewStatus, { state: { error: '图片预览格式无效。' } })
123
+ if (failedSrc === src) return React.createElement(PreviewStatus, { state: {
124
+ error: '浏览器无法解码这张图片,请检查图片文件或重新生成预览。', retry: () => setFailedSrc(null),
125
+ } })
73
126
  return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
74
127
  React.createElement('span', { className: 'dip-label' }, label),
75
128
  React.createElement('figure', { className: 'dip-figure' },
@@ -77,6 +130,7 @@ window.__ModuleLoader__.load({
77
130
  className: 'dip-img' + (isSvg ? ' svg' : ''),
78
131
  src: src,
79
132
  alt: label,
133
+ onError: () => setFailedSrc(src),
80
134
  })))
81
135
  }
82
136
 
@@ -123,15 +177,17 @@ window.__ModuleLoader__.load({
123
177
  * rows[0] 为表头;数字单元格右对齐;表头点击循环排序(升→降→取消)。
124
178
  */
125
179
  function CsvToolView(props) {
126
- const meta = metaOf(props)
127
- const ok = meta !== null && typeof meta === 'object' && Array.isArray(meta.rows) && meta.rows.length > 0
180
+ const state = usePreview(props)
181
+ const meta = state.meta
182
+ const ok = meta !== null && typeof meta === 'object' && Array.isArray(meta.rows)
183
+ && meta.rows.length > 0 && meta.rows.every(Array.isArray)
128
184
  // hooks 必须无条件调用,放在提前 return 之前。
129
185
  const sortPair = React.useState(null) // null | { col, dir }
130
186
  const sort = sortPair[0]
131
187
  const setSort = sortPair[1]
188
+ React.useEffect(() => { setSort(null) }, [meta])
132
189
  if (!ok) {
133
- return React.createElement('div', { className: 'dip-root', 'data-dip-preview': true },
134
- React.createElement('span', { className: 'dip-missing' }, 'CSV 预览缺少数据(meta 缺失)'))
190
+ return React.createElement(PreviewStatus, { state: meta ? { error: 'CSV 预览数据无效。' } : state })
135
191
  }
136
192
  const label = typeof meta.label === 'string' && meta.label !== '' ? meta.label : 'CSV 预览'
137
193
 
@@ -166,21 +222,93 @@ window.__ModuleLoader__.load({
166
222
  }
167
223
 
168
224
  // ── 服务接入 ─────────────────────────────────────────────────────────────
169
- const inject = []
225
+ const PREVIEWS_KEY = 'csv-and-image-preview'
226
+ const isPreviewTool = name => name === 'preview_image' || name === 'preview_csv'
227
+
228
+ // Replay successful native and PTC previews as dedicated Chat nodes.
229
+ // Their end-of-turn anchors remain outside the collapsible process interval.
230
+ const previewCalls = {
231
+ kind: PREVIEWS_KEY + '/calls',
232
+ target: 'chat',
233
+ match(event) {
234
+ if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
235
+ if (event.type === 'tool/result' && event.surfaceOp === 'append') {
236
+ return { id: String(event.data.message.source.callId), role: 'update' }
237
+ }
238
+ if (event.type === 'tool/ptc-dispatch' && typeof event.data.rootCallId === 'string') {
239
+ return { id: event.data.rootCallId, role: 'update' }
240
+ }
241
+ return null
242
+ },
243
+ start(_context, match) { return { name: match.event.data.name, previews: [] } },
244
+ update(context, match) {
245
+ const { event } = match
246
+ const nested = event.type === 'tool/ptc-dispatch'
247
+ const toolName = nested ? event.data.name : context.state.name
248
+ const result = nested ? event.data : event.data.message.content[0]
249
+ if (!isPreviewTool(toolName) || !result || result.isError) return context.state
250
+ const content = result.content || []
251
+ if (content.some(item => item.type === 'text' && /^preview_(?:image|csv) 失败:/.test(item.text))) return context.state
252
+ const callId = String(nested ? event.data.subCallId : event.data.message.source.callId)
253
+ const preview = { callId, toolName, seq: event.seq,
254
+ block: { kind: 'tool-result', isError: false, content,
255
+ ...!nested && event.data.meta ? { meta: event.data.meta } : {} } }
256
+ return { ...context.state, previews: [...context.state.previews.filter(item => item.callId !== callId), preview] }
257
+ },
258
+ buildViewNode(context) {
259
+ const location = context.start?.location
260
+ if (!context.state || !location?.turn) return null
261
+ const end = location.turn.end
262
+ return { key: context.key, id: context.id, kind: context.kind, target: 'chat', location,
263
+ // Placing this row after turn/end keeps it outside the process interval.
264
+ // A hidden node keeps its identity while the toolview handles live output.
265
+ anchorSeq: end ? end.seq + 0.01 : context.start.event.seq,
266
+ visibility: end && context.state.previews.length ? 'visible' : 'hidden',
267
+ data: { previews: context.state.previews } }
268
+ },
269
+ }
270
+ function ChatPreviews(props) {
271
+ return React.createElement('div', { className: 'dip-root', 'data-dip-turn-previews': true },
272
+ props.node.data.previews.map(preview => React.createElement(preview.toolName === 'preview_image' ? PreviewToolView : CsvToolView,
273
+ { ...props, ...preview, key: props.sessionId + ':' + preview.callId })))
274
+ }
275
+
276
+ const inject = ['slots', 'connection', 'uiConversation']
170
277
  function apply(ctx) {
171
- const slots = ctx.get('slots')
172
- if (slots === undefined) return
173
- const disposals = [
278
+ const slots = ctx.slots
279
+ const connection = ctx.connection
280
+ const bindings = {
281
+ async loadPreview(request, signal) {
282
+ const result = await connection.rpc.call('/api', 'csv-and-image-preview/get', request, signal)
283
+ if (!result.ok) throw new Error(result.error.message)
284
+ const value = result.value
285
+ if (request.toolName === 'preview_image' ? !validImage(value)
286
+ : !value || !Array.isArray(value.rows) || !value.rows.length || !value.rows.every(Array.isArray)) {
287
+ throw new Error('返回的预览数据无效。')
288
+ }
289
+ return result.value
290
+ },
291
+ subscribeConnection: listener => connection.generation.subscribe(listener),
292
+ getConnection: () => connection.generation.getSnapshot(),
293
+ }
294
+ ctx.effect(() => {
295
+ const disposals = [
296
+ ctx.uiConversation.events.register(previewCalls),
174
297
  slots.inject('tool.call.toolview', () => slots.register(
175
- { name: 'tool.call.toolview', key: 'preview_image' },
298
+ { name: 'tool.call.toolview', key: 'preview_image', inject: () => bindings },
176
299
  PreviewToolView,
177
300
  )),
178
301
  slots.inject('tool.call.toolview', () => slots.register(
179
- { name: 'tool.call.toolview', key: 'preview_csv' },
302
+ { name: 'tool.call.toolview', key: 'preview_csv', inject: () => bindings },
180
303
  CsvToolView,
181
304
  )),
182
- ]
183
- return () => { for (const dispose of disposals) dispose() }
305
+ slots.inject('conversation.chat.node', () => slots.register(
306
+ { name: 'conversation.chat.node', key: PREVIEWS_KEY + '/calls', inject: () => bindings },
307
+ ChatPreviews,
308
+ )),
309
+ ]
310
+ return () => { for (const dispose of disposals) dispose() }
311
+ }, 'preview: toolviews')
184
312
  }
185
313
 
186
314
  exports.apply = apply
package/lib/index.js CHANGED
@@ -6,24 +6,20 @@
6
6
  * 推断 MIME、base64 编码为 data-URI;
7
7
  * 2. 注册 `preview_csv` 工具:读取 CSV/TSV(磁盘路径或内联文本),嗅探分隔符、
8
8
  * 有界解析(输入体积/行数/列数/单元格四重截断),产出可序列化的表格 meta;
9
- * 3. 两者都把展示数据通过工具结果的 `presentationMeta` 投递出去,浏览器端
10
- * 键控 toolview 据此渲染原生 <img> / <table> —— 绕过聊天渲染器的过滤;
11
- * 4. `execute` 返回紧凑的模型可读摘要,真正的图片字节/表格数据只走 meta,
12
- * 不进模型上下文。
13
- *
14
- * 注意:`output.presentationMeta` 必须**同步**返回 lossless JSON(它是展示
15
- * 期别名,在结果落地时就读取,不能返回 Promise),因此这里用同步 fs 读取;
16
- * `execute` 本身是 async,但内部调用同一个同步构建器。
9
+ * 3. 以会话和调用 ID 保存快照,通过已认证的 Connection RPC 投递到浏览器;
10
+ * 4. `execute` 仅返回摘要。直接调用和 run_code 子调用使用同一通道,
11
+ * 不依赖仅顶层调用才计算的 presentationMeta,文件也只读取一次。
17
12
  *
18
13
  * 不导入 cordis/dsh-* 运行时包中的 Service/Context 类:仅用 ctx API 与 Node
19
14
  * 内建能力,与宿主进程共享同一套运行时实例。
20
15
  * @module dsh-csv-and-image-preview
21
16
  */
22
17
 
23
- import { readFileSync, statSync } from 'node:fs'
24
- import { resolve } from 'node:path'
25
-
26
- export const name = 'csv-and-image-preview'
18
+ import { resolve } from 'node:path'
19
+ import { PreviewStore, readBounded, MAX_INPUT_BYTES, executionIdentity, createPreviewRoute } from './store.js'
20
+
21
+ export const name = 'csv-and-image-preview'
22
+ export const inject = ['tools', 'connection']
27
23
 
28
24
  /** 扩展名 → MIME 推断表。 */
29
25
  const MIME_BY_EXT = {
@@ -70,10 +66,9 @@ function imageSummary(mime) {
70
66
  }
71
67
 
72
68
  /**
73
- * 同步构建一次预览结果,返回 { src, mime, label, bytes, summary };有错抛 Error。
74
- * 同步是因为 `presentationMeta` 必须同步返回 lossless JSON。
75
- */
76
- function buildPreviewSync(args) {
69
+ * 构建一次预览结果,返回 { src, mime, label, bytes, summary };有错抛 Error。
70
+ */
71
+ async function buildPreview(args, exec) {
77
72
  if (typeof args !== 'object' || args === null) {
78
73
  throw new Error('preview_image 需要对象参数(带 path 或 content)。')
79
74
  }
@@ -81,29 +76,34 @@ function buildPreviewSync(args) {
81
76
 
82
77
  // 优先:磁盘文件路径。
83
78
  if (typeof args.path === 'string' && args.path !== '') {
84
- const abs = resolve(args.path)
85
- const mime = mimeOf(abs)
86
- const buf = readFileSync(abs)
87
- const st = statSync(abs)
79
+ const abs = resolve(exec?.agent?.session?.header?.cwd || process.cwd(), args.path)
80
+ const mime = mimeOf(abs)
81
+ if (!Object.values(MIME_BY_EXT).includes(mime)) throw new Error('不支持此图片格式。')
82
+ const buf = await readBounded(abs, MAX_INPUT_BYTES, exec?.signal)
88
83
  const b64 = buf.toString('base64')
89
84
  return {
90
85
  src: `data:${mime};base64,${b64}`,
91
86
  mime,
92
87
  label,
93
- bytes: st.size,
88
+ bytes: buf.length,
94
89
  summary: imageSummary(mime),
95
90
  }
96
91
  }
97
92
 
98
93
  // 备选:调用方直接给内容。
99
94
  if (typeof args.content === 'string' && args.content !== '') {
100
- const mime = args.mime === 'image/svg+xml' ? 'image/svg+xml' : (typeof args.mime === 'string' ? args.mime : 'image/svg+xml')
101
- if (mime === 'image/svg+xml') {
102
- const b64 = Buffer.from(args.content, 'utf8').toString('base64')
103
- return { src: `data:image/svg+xml;base64,${b64}`, mime, label, bytes: b64.length, summary: `inline SVG ${svgDimension(args.content)}` }
104
- }
105
- // 假定 content 已是 base64。
106
- return { src: `data:${mime};base64,${args.content}`, mime, label, bytes: args.content.length, summary: `inline ${mime}` }
95
+ const mime = args.mime === 'image/svg+xml' ? 'image/svg+xml' : (typeof args.mime === 'string' ? args.mime : 'image/svg+xml')
96
+ if (!Object.values(MIME_BY_EXT).includes(mime)) throw new Error('不支持此图片 MIME。')
97
+ if (Buffer.byteLength(args.content, 'utf8') > MAX_INPUT_BYTES) throw new Error('内联图片超过 8 MB 预览上限。')
98
+ if (mime === 'image/svg+xml') {
99
+ const b64 = Buffer.from(args.content, 'utf8').toString('base64')
100
+ return { src: `data:image/svg+xml;base64,${b64}`, mime, label, bytes: Buffer.byteLength(args.content), summary: `inline SVG ${svgDimension(args.content)}` }
101
+ }
102
+ const b64 = args.content.replace(/\s/g, '')
103
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(b64) || !b64) {
104
+ throw new Error('图片 content 必须是有效的 base64。')
105
+ }
106
+ return { src: `data:${mime};base64,${b64}`, mime, label, bytes: Buffer.from(b64, 'base64').length, summary: `inline ${mime}` }
107
107
  }
108
108
 
109
109
  throw new Error('preview_image 需要 path 或 content 参数。')
@@ -111,15 +111,15 @@ function buildPreviewSync(args) {
111
111
 
112
112
  /**
113
113
  * 构建 preview_image 工具定义。注册进工具注册表后,模型可用它发起一次
114
- * “预览”:宿主读取图片并投递 data-URI meta,浏览器端 toolview 渲染。
114
+ * “预览”:宿主读取图片并保存快照,浏览器端 toolview 按调用 ID 读取。
115
115
  */
116
- export function createPreviewTool() {
116
+ export function createPreviewTool(store = new PreviewStore()) {
117
117
  return {
118
118
  name: 'preview_image',
119
119
  description:
120
120
  '在聊天里预览一张图片或 SVG——用浏览器原生 <img> 渲染,绕过聊天渲染器对图片的过滤。'
121
121
  + ' 传 path 读取磁盘文件,或传 content(内联 SVG 文本)直接预览。'
122
- + ' 图片字节只走 meta 投递到浏览器,不进入模型上下文;返回的是紧凑摘要。'
122
+ + ' 图片快照通过独立通道投递到浏览器,不进入模型上下文;返回的是紧凑摘要。'
123
123
  + ' 生成/修改图片时先调用它给用户预览,等用户确认后再做真正的写入/修改。',
124
124
  parameters: {
125
125
  type: 'object',
@@ -148,24 +148,12 @@ export function createPreviewTool() {
148
148
  render(_args, value) {
149
149
  return [{ type: 'text', text: String(value) }]
150
150
  },
151
- presentationMeta(args) {
152
- // 必须同步返回 lossless JSON(genui 的 render_ui 正是如此:从 args 直接推导)
153
- try {
154
- const r = buildPreviewSync(args)
155
- return { src: r.src, mime: r.mime, label: r.label }
156
- } catch {
157
- return null
158
- }
159
- },
160
- },
161
- async execute(args) {
162
- try {
163
- const r = buildPreviewSync(args)
164
- return `已渲染预览「${r.label}」:${r.summary}(${r.bytes} B)。图片已显示在聊天中,等待用户确认后再做真正的修改。`
165
- } catch (error) {
166
- const detail = error instanceof Error ? error.message : String(error)
167
- return `preview_image 失败:${detail}`
168
- }
151
+ },
152
+ async execute(args, exec) {
153
+ const identity = executionIdentity(exec, 'preview_image')
154
+ const r = await buildPreview(args, exec)
155
+ await store.put(identity, { src: r.src, mime: r.mime, label: r.label }, exec.signal)
156
+ return `预览数据已生成「${r.label}」:${r.summary}(${r.bytes} B)。请查看预览,等待用户确认后再做真正的修改。`
169
157
  },
170
158
  presentCall(args) {
171
159
  return { card: 'generic', title: `预览「${labelOf(args, '预览图片')}」`, kind: 'other' }
@@ -277,11 +265,11 @@ function capCell(c) {
277
265
  }
278
266
 
279
267
  /**
280
- * 同步构建 CSV 预览 meta,返回 { label, delimiter, rows, totalRows, totalCols,
268
+ * 构建 CSV 预览快照,返回 { label, delimiter, rows, totalRows, totalCols,
281
269
  * rowsShown, colsShown };有错抛 Error。rows 含表头行,已被列数/单元格截断并
282
270
  * 补齐成规则矩形,浏览器可直接渲染。
283
271
  */
284
- function buildCsvPreviewSync(args) {
272
+ async function buildCsvPreview(args, exec) {
285
273
  if (typeof args !== 'object' || args === null) {
286
274
  throw new Error('preview_csv 需要对象参数(带 path 或 content)。')
287
275
  }
@@ -289,12 +277,10 @@ function buildCsvPreviewSync(args) {
289
277
 
290
278
  let text = ''
291
279
  if (typeof args.path === 'string' && args.path !== '') {
292
- const abs = resolve(args.path)
293
- const buf = readFileSync(abs)
294
- if (buf.length > CSV_MAX_INPUT_BYTES) {
295
- throw new Error(`文件 ${fmtBytes(buf.length)} 超过预览上限 ${fmtBytes(CSV_MAX_INPUT_BYTES)},请先截取再预览。`)
296
- }
297
- text = buf.toString('utf8')
280
+ const abs = resolve(exec?.agent?.session?.header?.cwd || process.cwd(), args.path)
281
+ const buf = await readBounded(abs, CSV_MAX_INPUT_BYTES, exec?.signal)
282
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf) }
283
+ catch { throw new Error('CSV 不是有效的 UTF-8 编码,请转换为 UTF-8 后重试。') }
298
284
  } else if (typeof args.content === 'string' && args.content !== '') {
299
285
  if (Buffer.byteLength(args.content, 'utf8') > CSV_MAX_INPUT_BYTES) {
300
286
  throw new Error(`内联内容超过预览上限 ${fmtBytes(CSV_MAX_INPUT_BYTES)},请先截取再预览。`)
@@ -339,9 +325,9 @@ function csvSummaryText(r) {
339
325
 
340
326
  /**
341
327
  * 构建 preview_csv 工具定义。模型传 CSV 路径/文本发起一次「表格预览」:
342
- * 宿主有界解析后把表格投递到 meta,浏览器端 toolview 渲染原生 <table>。
328
+ * 宿主有界解析后保存表格快照,浏览器端 toolview 渲染原生 <table>。
343
329
  */
344
- export function createCsvTool() {
330
+ export function createCsvTool(store = new PreviewStore()) {
345
331
  return {
346
332
  name: 'preview_csv',
347
333
  description:
@@ -349,7 +335,7 @@ export function createCsvTool() {
349
335
  + ' 传 path 读取磁盘文件(.csv/.tsv 等),或传 content(内联 CSV 文本)。'
350
336
  + ' 分隔符自动嗅探(, ; Tab |),可用 delimiter 指定;默认只投递前 '
351
337
  + `${CSV_MAX_ROWS_DEFAULT} 行(可用 maxRows 调大,上限 ${CSV_MAX_ROWS_LIMIT})。`
352
- + ' 表格数据只走 meta 投递到浏览器,不进入模型上下文;返回的是行列摘要。'
338
+ + ' 表格快照通过独立通道投递到浏览器,不进入模型上下文;返回的是行列摘要。'
353
339
  + ' 生成/修改 CSV 时先调用它给用户预览,等用户确认后再做真正的写入/修改。',
354
340
  parameters: {
355
341
  type: 'object',
@@ -383,23 +369,12 @@ export function createCsvTool() {
383
369
  render(_args, value) {
384
370
  return [{ type: 'text', text: String(value) }]
385
371
  },
386
- presentationMeta(args) {
387
- // 必须同步返回 lossless JSON;解析全程同步。
388
- try {
389
- return buildCsvPreviewSync(args)
390
- } catch {
391
- return null
392
- }
393
- },
394
- },
395
- async execute(args) {
396
- try {
397
- const r = buildCsvPreviewSync(args)
398
- return `已渲染 CSV 预览「${r.label}」:${csvSummaryText(r)}表格已显示在聊天中,等待用户确认后再做真正的修改。`
399
- } catch (error) {
400
- const detail = error instanceof Error ? error.message : String(error)
401
- return `preview_csv 失败:${detail}`
402
- }
372
+ },
373
+ async execute(args, exec) {
374
+ const identity = executionIdentity(exec, 'preview_csv')
375
+ const r = await buildCsvPreview(args, exec)
376
+ await store.put(identity, r, exec.signal)
377
+ return `CSV 预览数据已生成「${r.label}」:${csvSummaryText(r)}请查看预览,等待用户确认后再做真正的修改。`
403
378
  },
404
379
  presentCall(args) {
405
380
  return { card: 'generic', title: `CSV 预览「${labelOf(args, 'CSV 表格')}」`, kind: 'other' }
@@ -410,25 +385,10 @@ export function createCsvTool() {
410
385
  }
411
386
  }
412
387
 
413
- /**
414
- * 注册工具。`tools` 服务可能晚于本插件绑定(启动顺序),因此既在 apply 时
415
- * 探测一次,也订阅 `internal/service`(cordis 在每次服务绑定时发出),确保
416
- * 一旦工具注册表出现就立即注册。
417
- */
418
- export function apply(ctx) {
419
- let registered = false
420
- const tryRegister = (value) => {
421
- if (registered) return
422
- const tools = value ?? ctx.reflect.get('tools', false)
423
- if (tools === undefined) return
424
- tools.register(createPreviewTool())
425
- tools.register(createCsvTool())
426
- registered = true
427
- }
428
- tryRegister(undefined)
429
- if (typeof ctx.on === 'function') {
430
- ctx.on('internal/service', (name, value) => {
431
- if (name === 'tools') tryRegister(value)
432
- })
433
- }
434
- }
388
+ /** Service declarations park this plugin until dependencies are active. */
389
+ export function apply(ctx) {
390
+ const store = new PreviewStore()
391
+ ctx.effect(() => ctx.connection.fetch.register(createPreviewRoute(store)), 'preview: exact RPC route')
392
+ ctx.tools.register(createPreviewTool(store))
393
+ ctx.tools.register(createCsvTool(store))
394
+ }
package/lib/store.js ADDED
@@ -0,0 +1,144 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { mkdir, open, rename, unlink } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+
6
+ export const PREVIEW_ENDPOINT = 'csv-and-image-preview/get'
7
+ export const MAX_INPUT_BYTES = 8 * 1024 * 1024
8
+ const MAX_RECORD_BYTES = 16 * 1024 * 1024
9
+
10
+ function defaultDirectory() {
11
+ let home = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
12
+ if (home === '~') home = homedir()
13
+ else if (/^~[/\\]/.test(home)) home = join(homedir(), home.slice(2))
14
+ return resolve(home, 'storages', 'csv-and-image-preview')
15
+ }
16
+
17
+ /** Bound the allocation even when a source file grows after stat(). */
18
+ export async function readBounded(path, limit = MAX_INPUT_BYTES, signal) {
19
+ signal?.throwIfAborted()
20
+ const file = await open(path, 'r')
21
+ try {
22
+ const info = await file.stat()
23
+ if (!info.isFile()) throw new Error('预览路径必须是普通文件。')
24
+ if (info.size > limit) throw new Error(`文件超过预览上限 ${limit / 1024 / 1024} MB。`)
25
+ const buffer = Buffer.alloc(Math.min(info.size + 1, limit + 1))
26
+ let length = 0
27
+ while (length < buffer.length) {
28
+ signal?.throwIfAborted()
29
+ const { bytesRead } = await file.read(buffer, length, buffer.length - length, null)
30
+ if (bytesRead === 0) break
31
+ length += bytesRead
32
+ }
33
+ signal?.throwIfAborted()
34
+ if (length > limit || length > info.size) throw new Error('文件在读取时变大,请重试预览。')
35
+ return buffer.subarray(0, length)
36
+ } finally {
37
+ await file.close()
38
+ }
39
+ }
40
+
41
+ function validIdentity(value) {
42
+ return typeof value === 'string' && value.length > 0 && value.length <= 512
43
+ }
44
+
45
+ export function validateRequest(request) {
46
+ if (!request || !validIdentity(request.sessionId) || !validIdentity(request.callId)
47
+ || !['preview_image', 'preview_csv'].includes(request.toolName)) {
48
+ throw new Error('无效的预览请求。')
49
+ }
50
+ }
51
+
52
+ export function executionIdentity(exec, toolName) {
53
+ const identity = { sessionId: exec?.agent?.session?.id, callId: exec?.callId, toolName }
54
+ validateRequest(identity)
55
+ return identity
56
+ }
57
+
58
+ /** Immutable per-call snapshots, outside model output and original workspace files. */
59
+ export class PreviewStore {
60
+ constructor(directory = defaultDirectory()) { this.directory = resolve(directory) }
61
+
62
+ path(identity) {
63
+ validateRequest(identity)
64
+ const key = createHash('sha256').update(JSON.stringify([
65
+ identity.sessionId, identity.callId, identity.toolName,
66
+ ])).digest('hex')
67
+ return join(this.directory, `${key}.json`)
68
+ }
69
+
70
+ async put(identity, payload, signal) {
71
+ const target = this.path(identity)
72
+ const body = JSON.stringify({ version: 1, ...identity, payload })
73
+ if (Buffer.byteLength(body) > MAX_RECORD_BYTES) throw new Error('预览快照超过存储上限。')
74
+ signal?.throwIfAborted()
75
+ await mkdir(this.directory, { recursive: true })
76
+ const temporary = `${target}.${randomUUID()}.tmp`
77
+ try {
78
+ const file = await open(temporary, 'wx', 0o600)
79
+ try {
80
+ await file.writeFile(body, { encoding: 'utf8', signal })
81
+ await file.sync()
82
+ } finally { await file.close() }
83
+ signal?.throwIfAborted()
84
+ await rename(temporary, target)
85
+ } finally {
86
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error })
87
+ }
88
+ }
89
+
90
+ async get(identity, signal) {
91
+ let bytes
92
+ try { bytes = await readBounded(this.path(identity), MAX_RECORD_BYTES, signal) }
93
+ catch (error) { if (error.code === 'ENOENT') return null; throw error }
94
+ const record = JSON.parse(bytes.toString('utf8'))
95
+ if (record.version !== 1 || record.sessionId !== identity.sessionId
96
+ || record.callId !== identity.callId || record.toolName !== identity.toolName) {
97
+ throw new Error('预览快照与当前会话不匹配。')
98
+ }
99
+ return record.payload
100
+ }
101
+ }
102
+
103
+ /** Connection applies browser authentication before this handler is invoked. */
104
+ export function createPreviewHandler(store) {
105
+ return async (_endpoint, request, signal) => {
106
+ try { validateRequest(request) }
107
+ catch { return { ok: false, error: { code: 'preview/invalid', message: '无效的预览请求。', details: {} } } }
108
+ try {
109
+ const value = await store.get(request, signal)
110
+ if (value === null) return { ok: false, error: {
111
+ code: 'preview/missing', message: '未找到此调用的预览快照,旧版嵌套调用需要重新预览。', details: {},
112
+ } }
113
+ return { ok: true, value }
114
+ } catch {
115
+ return { ok: false, error: { code: 'preview/read', message: '读取预览快照失败,请重试。', details: {} } }
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Own only this exact path. The single shared /api interceptor belongs to DSH's Gateway. */
121
+ export function createPreviewRoute(store) {
122
+ const handle = createPreviewHandler(store)
123
+ return {
124
+ path: `/api/${PREVIEW_ENDPOINT}`,
125
+ methods: ['POST'],
126
+ requestBody: 'buffered',
127
+ async fetch(request) {
128
+ if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
129
+ return new Response('content type must be application/json', { status: 415 })
130
+ }
131
+ let message
132
+ try { message = await request.json() }
133
+ catch { return new Response('invalid JSON', { status: 400 }) }
134
+ if (!message || message.type !== 'client-request' || !validIdentity(message.rpcId)
135
+ || message.method !== PREVIEW_ENDPOINT) {
136
+ return new Response('invalid preview RPC envelope', { status: 400 })
137
+ }
138
+ return Response.json({
139
+ type: 'server-response', rpcId: message.rpcId,
140
+ result: await handle(PREVIEW_ENDPOINT, message.payload, request.signal),
141
+ }, { headers: { 'Cache-Control': 'private, no-store' } })
142
+ },
143
+ }
144
+ }
@@ -2,7 +2,18 @@
2
2
  * Public type surface for the dsh-csv-and-image-preview host half.
3
3
  * The runtime is plain JS; these declarations describe the cordis plugin
4
4
  * contract so TS consumers and the `.d.ts` re-export resolve cleanly.
5
- */
5
+ */
6
+
7
+ /** Execution identity supplied by DSH for native and run_code child calls. */
8
+ export interface PreviewExecution {
9
+ callId: string
10
+ signal: AbortSignal
11
+ agent: { session: { id: string; header: { cwd?: string } } }
12
+ }
13
+
14
+ export interface PreviewSnapshotStore {
15
+ put(identity: { sessionId: string; callId: string; toolName: string }, payload: unknown, signal?: AbortSignal): Promise<void>
16
+ }
6
17
 
7
18
  /** The `preview_image` tool definition (a dsh ToolDefinition object). */
8
19
  export interface PreviewToolDefinition {
@@ -10,7 +21,7 @@ export interface PreviewToolDefinition {
10
21
  description: string
11
22
  parameters: Record<string, unknown>
12
23
  output: Record<string, unknown>
13
- execute(args: unknown): Promise<string>
24
+ execute(args: unknown, exec: PreviewExecution): Promise<string>
14
25
  presentCall(args: unknown): { card: string; title: string; kind: string } | undefined
15
26
  presentResult(args: unknown): { card: string; title: string } | undefined
16
27
  }
@@ -21,7 +32,7 @@ export interface CsvToolDefinition {
21
32
  description: string
22
33
  parameters: Record<string, unknown>
23
34
  output: Record<string, unknown>
24
- execute(args: unknown): Promise<string>
35
+ execute(args: unknown, exec: PreviewExecution): Promise<string>
25
36
  presentCall(args: unknown): { card: string; title: string; kind: string } | undefined
26
37
  presentResult(args: unknown): { card: string; title: string } | undefined
27
38
  }
@@ -50,5 +61,8 @@ export interface CsvPreviewMeta {
50
61
  colsShown: number
51
62
  }
52
63
 
53
- declare const plugin: ImagePreviewPlugin
54
- export default plugin
64
+ export declare const name: 'csv-and-image-preview'
65
+ export declare const inject: string[]
66
+ export declare function apply(ctx: unknown): void
67
+ export declare function createPreviewTool(store?: PreviewSnapshotStore): PreviewToolDefinition
68
+ export declare function createCsvTool(store?: PreviewSnapshotStore): CsvToolDefinition
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetecho/dsh-csv-and-image-preview",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Preview images / SVG and CSV tables in the DeepSeek Harness chat, rendered as real browser <img> / <table> elements. Preview-first workflow: show the user the asset, wait for approval, then apply the real change.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -24,6 +24,8 @@
24
24
  "LICENSE"
25
25
  ],
26
26
  "scripts": {
27
+ "test": "node --test tests/*.test.mjs",
28
+ "preview:test": "node scripts/preview-test.mjs",
27
29
  "build": "node scripts/build.mjs",
28
30
  "prepack": "node scripts/prepack.mjs"
29
31
  },
@@ -32,7 +34,11 @@
32
34
  "patch": "./cordis.patch.yml"
33
35
  },
34
36
  "client": {
35
- "inject": [],
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-connection",
39
+ "@deepseek-ai/dsh-client-ui-renderer",
40
+ "@deepseek-ai/dsh-client-ui-conversation"
41
+ ],
36
42
  "platform": "web"
37
43
  }
38
44
  },
@@ -58,10 +64,18 @@
58
64
  },
59
65
  "homepage": "https://github.com/jetecho/dsh-csv-and-image-preview#readme",
60
66
  "peerDependencies": {
61
- "@deepseek-ai/cordis": "^4.0.1",
67
+ "@deepseek-ai/cordis": "^4.0.2",
62
68
  "react": "^18.0.0 || ^19.0.0"
63
69
  },
64
- "dependencies": {
65
- "react": "^18.3.1"
70
+ "devDependencies": {
71
+ "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
72
+ "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
73
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
74
+ "@deepseek-ai/dsh-tools": "0.1.5-rc.1",
75
+ "immer": "^10.2.0",
76
+ "react": "^18.3.1",
77
+ "react-dom": "^18.3.1",
78
+ "react-test-renderer": "^18.3.1",
79
+ "zustand": "~4.4.7"
66
80
  }
67
81
  }