@crosery/dsh-viewer 0.0.0-stage → 0.3.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Crosery
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,183 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <img src="assets/logo.png" alt="dsh-viewer" width="132">
4
+
5
+ # @crosery/dsh-viewer
6
+
7
+ **Everything renders.** One `display_file` tool that puts images, video, audio, PDF, Office documents and local web pages inline in the [DeepSeek Harness](https://github.com/deepseek-ai) conversation, on the web and in the desktop app — with a real player, not a filename and a byte count.
8
+
9
+ [English](README.md) · [中文](README.zh.md) · MIT
10
+
11
+ [![npm version](https://img.shields.io/npm/v/@crosery/dsh-viewer)](https://www.npmjs.com/package/@crosery/dsh-viewer)
12
+ [![npm downloads](https://img.shields.io/npm/dm/@crosery/dsh-viewer)](https://www.npmjs.com/package/@crosery/dsh-viewer)
13
+ [![Harness compatibility](https://github.com/Crosery/dsh-viewer/actions/workflows/harness-compat.yml/badge.svg)](https://github.com/Crosery/dsh-viewer/actions/workflows/harness-compat.yml)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## What it does
20
+
21
+ The harness ships `read_image`, whose job is to put a picture into **model context**. It refuses on a text-only route, it handles four raster formats, and up to harness 0.1.2 the built-in web client draws no card for it — so the human in front of the screen sees one line of text. (From 0.1.3 the harness draws its own `read_image` view, and this plugin's card for that key steps aside.)
22
+
23
+ This plugin inverts that. Its job is to put a file **on your screen**. A text-only model route is a normal outcome, not a refusal, and every medium a browser can play is in scope.
24
+
25
+ **36 file extensions across 6 render kinds:**
26
+
27
+ | Kind | Element | Extensions |
28
+ | --- | --- | --- |
29
+ | Image | `<img>` + click-to-zoom lightbox | `png` `jpg` `jpeg` `webp` `gif` `svg` `avif` `bmp` `ico` `apng` |
30
+ | Video | `<video controls>`, seekable | `mp4` `m4v` `webm` `ogv` `mov` |
31
+ | Audio | `<audio controls>` | `mp3` `m4a` `aac` `wav` `flac` `ogg` `oga` `opus` |
32
+ | PDF | embedded viewer | `pdf` |
33
+ | Document | converted to PDF, embedded | `docx` `doc` `rtf` `odt` `xlsx` `xls` `ods` `pptx` `ppt` `odp` |
34
+ | Web page | sandboxed `<iframe>` | `html` `htm` |
35
+
36
+ Anything else still gets a card with its type, size and an open link — there is no kind that renders nothing.
37
+
38
+ On a model route that accepts image input, a `png` / `jpg` / `jpeg` / `webp` / `gif` **also** enters the model's own context, so one call both shows you the file and lets the model see it. Everything else is shown to you only, and the card says so.
39
+
40
+ ## Screenshots
41
+
42
+ Images, video and audio in one turn — real players, real scrub bars:
43
+
44
+ ![Images, video and audio rendered inline](assets/screenshot-1-media.png)
45
+
46
+ Word, Excel and PowerPoint, converted on the host and embedded:
47
+
48
+ ![Office documents rendered inline](assets/screenshot-2-office.png)
49
+
50
+ PDF and a local HTML page, live:
51
+
52
+ ![PDF and HTML rendered inline](assets/screenshot-3-pdf-html.png)
53
+
54
+ ## Install
55
+
56
+ ```sh
57
+ dsh plugin --profile web add @crosery/dsh-viewer
58
+ ```
59
+
60
+ Then add the package name to `dsh.profile.bundles` in your profile's `package.json` and **restart the profile** — bundle membership changes do not hot-reload.
61
+
62
+ The repository ships its built halves, so this needs no build step and no `allowBuilds` entry in your profile. The same code is attached to every release as a tarball if you prefer a pinned, versioned install:
63
+
64
+ ```sh
65
+ dsh plugin --profile web add https://github.com/Crosery/dsh-viewer/releases/latest/download/dsh-viewer.tgz
66
+ ```
67
+
68
+ **On the desktop app**, open **Plugins → Add plugin** and paste the release tarball URL above. The `dsh` CLI refuses the desktop profile (`profile "desktop" is managed exclusively by the Electron application`), so this is the only way in. Upgrading an installed plugin there needs an app restart.
69
+
70
+ **Harness 0.1.7 — the desktop app included — needs 0.2.0 or later.** From 0.1.7 the harness checks a plugin's peer ranges itself, and v0.1.1's ranges stop below 0.1.6: installing it is refused, and an already-installed copy is skipped at boot without a word — the chat works, and nothing is ever displayed. **Harness 0.0.1 and 0.1.0-rc.2 – rc.7 need 0.2.1 or later**: earlier ranges started at 0.1.0-rc.8. Those trains check no ranges themselves, but npm and pnpm do.
71
+
72
+ Office rendering needs a converter. From harness 0.1.6-alpha.2 on — the desktop app included — the harness's own bundled converter handles `doc` `docx` `xls` `xlsx` `ppt` `pptx` with nothing else installed. `rtf` and the OpenDocument trio, and every format on older trains, need LibreOffice on `PATH` (or the macOS app bundle):
73
+
74
+ ```sh
75
+ brew install --cask libreoffice # macOS
76
+ ```
77
+
78
+ Without a converter, every other format still works and a document card says exactly what is missing.
79
+
80
+ Converted PDFs are cached in `$DSH_HOME/.dsh-viewer-cache/`, shared by every profile, so a document converts once per edit rather than once per display. The cache is bounded: an artifact unused for 30 days is removed, and beyond 256 documents or 512 MiB the least recently used go first. Serving a card's PDF counts as a use.
81
+
82
+ ## How the bytes reach the page
83
+
84
+ Two channels, in priority order.
85
+
86
+ **A signed HTTP route** (`/crosery/dsh-viewer/asset`) streams from the host. It is the only channel that can carry video, audio, PDF or HTML, and the only one that supports range requests — which is what makes a `<video>` seekable at all.
87
+
88
+ **The durable attachment store** is the fallback. Images only, but it is irreplaceable in two cases: a filesystem backend that exposes no local path (a remote workspace), and rendering a shipped `read_image` result, which has an attachment and no URL.
89
+
90
+ They are not redundant: a raster on a vision route still goes through the attachment store, because that is the only way it also reaches the model. From 0.1.7 durable images are read through the chat's own loader, which shares one read per image across the session.
91
+
92
+ In the desktop app, video and audio load from the Host's loopback address (`__DSH_TRANSPORT__.streamBaseUrl`) instead of the window's `dsh-app:` origin: the app's protocol forwarder drops `Content-Length`, and Chromium then treats the first load of a media URL as an unseekable stream. The route authorizes by signature, so nothing else changes.
93
+
94
+ ## Security
95
+
96
+ The route **never accepts a path from the browser.** At tool time the host signs the resolved absolute path with a per-harness HMAC key; path and MAC travel together in the URL, and the route honours a path only after the MAC verifies.
97
+
98
+ - Key: 32 random bytes at `$DSH_HOME/.dsh-viewer-asset-key`, mode `0600`, generated on first use. A self-contained signature rather than an in-process token table, because cards must survive a restart — a card replayed from a months-old session log holds only the URL it was minted with. The one exception is a converted document: its URL names a cached PDF, and once that has been evicted the card's frame answers `404` until `display_file` converts the file again.
99
+ - Tampering with the path, swapping keys, dropping the signature, or re-spelling the base64 with padding all return `404`. "Not signed by us" and "signed but the file is gone" return the *same* status, so a probe cannot learn whether a file exists.
100
+ - `text/html` and `image/svg+xml` ship with a `Content-Security-Policy` (`sandbox` and `default-src 'none'` respectively): navigating directly to such an asset would otherwise run its script on the app's origin. Everything carries `X-Content-Type-Options: nosniff`.
101
+ - Bytes stream with `createReadStream`, not `ctx.fs.readBytes` — the latter materializes the whole file in memory, and a two-hour video is precisely the case this plugin exists for.
102
+
103
+ ## Configuration
104
+
105
+ Four switches. Every default is the behaviour the plugin exists to provide; each flag gives one piece back.
106
+
107
+ | Field | Default | Turning it off |
108
+ | --- | --- | --- |
109
+ | `tool` | `true` | `display_file` is not registered at all |
110
+ | `redirectRead` | `true` | `read` may decode a raster into replacement characters again |
111
+ | `feedModel` | `true` | images are shown only, never added to model context |
112
+ | `supersedeReadImage` | `true` | the shipped `read_image` becomes visible to the model again |
113
+
114
+ Where to edit them depends on the harness train:
115
+
116
+ - **Up to 0.1.6**: the `crosery-viewer` section of `$DSH_HOME/settings.yaml` — hot-reloaded, no restart.
117
+ - **0.1.7 and later** (web and desktop): there is **no settings form** for this plugin. The Settings page builds forms only from fields a plugin marks volatile, and these are not. Set them on the plugin's `viewer` entry in the profile's own patch file, `$DSH_HOME/profiles/<profile>/cordis.patch.yml` — `~/.dsh/profiles/web/cordis.patch.yml` for `dsh --profile web`, `~/.dsh/profiles/desktop/cordis.patch.yml` for the desktop app — then restart the profile or the app:
118
+
119
+ ```yaml
120
+ - id: viewer
121
+ config:
122
+ redirectRead: false
123
+ ```
124
+
125
+ The row replaces the entry's `config` as a whole rather than merging into it, so a field it leaves out keeps its default. `settings.yaml` is gone on 0.1.7, and its one-time import moves each section into the entry of the same id, so a `crosery-viewer` section is **not** carried over — it stays behind in `settings.yaml.imported`.
126
+
127
+ ## Design notes
128
+
129
+ **`read` on binary media is not an error.** The shipped filesystem provider samples the file head and throws `FS_NOT_TEXT` on a NUL byte, so `read` aimed at a PNG paints a red failure row with or without this plugin — for a file that exists and is one call away from being on screen. Neither obvious correction removes it: a `tools/pre-execute` denial materializes its own error, and a `tools/post-execute` decision cannot replace the value of a failed result. So the correction runs in the `tools/execute` around-dispatch waterfall and never calls `next()` — no filesystem I/O happens at all, and the authored success is re-projected through the owning tool's own `render` and `presentationMeta`, which replaces the persisted read metadata too. The result is an ordinary successful read whose one line points at `display_file`.
130
+
131
+ **Guidance that divides the work.** A short system-prompt section tells the model when to call `display_file`. It sits right after the shipped read guidance — order 101 up to 0.1.1, `getSectionOrder('TOOL_READ') + 1` from 0.1.2 — and is empty for an agent that cannot call the tool. It mentions the `read` redirect only while `redirectRead` is on. Where the harness also offers `present`, it leaves deliverables to `present` and pictures inside an answer to markdown images, so one file is never displayed, embedded and presented at once.
132
+
133
+ **One image entry point.** `read_image` and `display_file` overlap on exactly one thing — putting a raster into model context — and a model offered both uses both. Measured on a real 2.2 MB PNG: the same image entered context twice in one turn. `display_file` is a strict superset, so `read_image` is hidden per agent via `tools.restrict()` on `agent/created`, retried on `tools/change` because the shipped tool registers behind an async service injection.
134
+
135
+ **Nested `run_code` calls get no `presentationMeta`.** The registry projects it only for top-level calls, so a `display_file` invoked from inside `run_code` would render as a bare header. The model-facing envelope therefore carries `<media>`, `<bytes>` and `<asset>` elements, and the card rebuilds from its own envelope when metadata is absent — validated through the same narrowing the replay path uses. The image of such a call reaches model context once: the code-mode transport already defers every child result that carries an image block, so the tool does not defer it again.
136
+
137
+ **Completed turns keep their files on screen.** From 0.1.6 the chat folds a completed turn's tool rows behind one "used N s" disclosure, and 0.1.7 does it by default. The plugin therefore also contributes to the chat's turn tail — the list between a turn's closing reply and its action row, where the harness's own delivery cards live — and shows that turn's displayed files there once the turn completes. That includes files displayed from inside `run_code`: those calls are logged as `tool/ptc-dispatch` events that carry no turn number, so they are collected when the turn ends, from where the conversation engine itself placed them. The tail stands down wherever the rows are visible anyway: the `verbose` transcript view, a turn that is still running, was aborted, or failed, and — on 0.1.7, whose chat keeps such a turn unfolded — a turn the user steered while it ran.
138
+
139
+ **The conversion cache survives restarts, and stays bounded.** The harness's bundled converter stamps each provider instance with a random `generation`, so keying artifacts on it made every restart a cold cache that was never hit again and only grew. The key is the converter's configuration instead — fonts, fallbacks, image resolution — plus the source's path, version and size: new settings are a new artifact, a restart is not. An engine upgrade under unchanged settings keeps serving the older render of the same bytes until it ages out. Each artifact is written under a partial name in the cache directory and renamed into place there, never renamed in from the system temp directory, which on Linux is often a separate `tmpfs` mount where `rename` fails with `EXDEV`. Pruning runs in the background at activation and after each write, and only ever removes regular files in the cache directory whose names the converters write.
140
+
141
+ **A PDF iframe must not be sandboxed.** `sandbox` without `allow-same-origin` gives an opaque origin, and Chrome's PDF viewer refuses to run there, showing "This page has been blocked by Chrome". Local HTML is the opposite case and keeps the sandbox.
142
+
143
+ ## Development
144
+
145
+ ```sh
146
+ npm install
147
+ npm run typecheck # host and client are separate programs — see below
148
+ npm run build # two .d.ts trees + two bundles; the result is committed
149
+ npm run check # repo invariants: README counts, locale keys, peer range, install path
150
+ npm run check:dist # the committed lib/ is byte-identical to a fresh build
151
+ npm test # 147 cases
152
+ ```
153
+
154
+ Two tsconfigs are required, not fastidiousness: both halves augment the same `@deepseek-ai/cordis` `Context`, and `sessions` is `SessionStore` on the host but `ISessions` in the browser. One program seeing both augmentations silently resolves the wrong one, because `skipLibCheck` hides the conflict.
155
+
156
+ `tests/asset-route.test.ts` mounts the handler on a real `node:http` server and issues real requests — range correctness cannot be proven by unit-testing the parser. `tests/convert.test.ts` builds a DOCX with LibreOffice, converts it back, and asserts the artifact starts with `%PDF-`.
157
+
158
+ ## Harness compatibility
159
+
160
+ | Where | Versions | Evidence |
161
+ | --- | --- | --- |
162
+ | Web (`dsh --profile web`) | every published version, `0.0.1-rc.1` through `0.2.2-alpha.2` — npm `latest` (`0.2.1-rc.5`), `next` (`0.2.1-rc.5`) and `alpha` (`0.2.2-alpha.2`) included. `0.0.1-rc.1` and `rc.2` are admitted, but their own harness cannot be installed by anyone | historical sweeps and smoke records cover the earlier versions (see the acceptance record); for this update, both typechecks, 209/209 tests and a strict nine-stage smoke passed on each of `0.2.1-rc.5` and `0.2.2-alpha.2` on 2026-11-01 UTC. CI's weekly sweep and the release gate cover all 38 installable versions; live card checks in a browser cover `0.0.1-rc.5`, `0.1.0-rc.2`, `0.1.0-rc.6`, `0.1.7-rc.2` and `0.2.1-alpha.1` (v0.1.1 also on `0.1.1-rc.2` and `0.1.5-rc.2`) |
163
+ | Desktop app | current `nightly` feed: `0.2.1-rc.5` (released 2026-10-29) | strict smoke of the matching npm Web runtime passed; the current desktop bytes await the release gate. Historical desktop runtime smoke and live macOS window checks: `0.1.7-rc.2` |
164
+
165
+ The peer ranges admit exactly that support, one comparator set per tuple, under npm's semver rule and under the prerelease-inclusive rule the harness applies itself from 0.1.7. The trains before `0.1.0-rc.8` have an older front end without `dsh-client-ui-renderer`; the plugin needs only the `slots` and `locale` services from it, which every train has. Unverified tuples such as `0.1.8` and `0.2.3` are admitted once CI has verified them.
166
+
167
+ CI keeps the claim honest. Every pull request runs typecheck, tests, peer admission and a real boot of the packed plugin, run in headless Chrome, on the pinned train, on the `0.1.1-rc.2` floor and on whatever the desktop app ships that day. A daily job repeats that against the desktop app and npm's `latest`, `next` and `alpha`, and against the desktop zip itself on macOS. A weekly job sweeps every published harness version and boots and runs the plugin on every one that installs, so a new one shows up without anyone adding it. A failure opens an `upstream-drift` issue, and the first green run closes it. Releases are gated on the same checks. Details: [docs/harness-compatibility.md](docs/harness-compatibility.md).
168
+
169
+ ## Known limitations
170
+
171
+ - Local file paths only; URLs are not accepted.
172
+ - Video and audio duration/resolution are not in the card header — that needs `ffprobe`. The player shows them.
173
+ - Where the plugin reads an attachment-only image itself (up to 0.1.5), the object URL lives until the plugin unloads, bounding held blobs by the distinct attachments displayed meanwhile. From 0.1.7 the chat's own loader owns them.
174
+ - On a remote workspace with no `processPath`, non-image media have no channel and the card says so.
175
+ - No transcoding: a codec the browser refuses (ProRes in a `.mov`) falls back to the `<video>` fallback text.
176
+ - In the desktop app, "Open in a new tab" becomes **Preview in sidebar** (the harness's own preview, PDF.js for PDFs): the desktop window silently refuses new tabs for app URLs. Everything inside the card — images, players, PDF and document frames — renders the same as on the web.
177
+ - The turn tail needs the list-shaped tail of 0.1.6+. On 0.1.2–0.1.5 a folded ("compact") completed turn keeps its cards inside the fold; switch the transcript view to `normal` there to keep them open. On 0.1.6 the `normal` view shows a card both in the turn and in its tail.
178
+ - If the user expands a folded turn, its displayed files appear twice — in their tool rows and in the tail — as the harness's own delivered files do.
179
+ - The tail recognizes a steered turn from the chat's own process anchor (its `turn-process` turn data). Should a future chat stop publishing that, a steered turn would show its cards twice — in its unfolded rows and in the tail — rather than lose them.
180
+
181
+ ## License
182
+
183
+ MIT © Crosery
package/README.zh.md ADDED
@@ -0,0 +1,255 @@
1
+ <div align="center">
2
+
3
+ <img src="assets/logo.png" alt="dsh-viewer" width="132">
4
+
5
+ # @crosery/dsh-viewer
6
+
7
+ **万物皆可渲染。** 一个 `display_file` 工具,把图片、视频、音频、PDF、Office 文档和本地网页直接渲染进 DeepSeek Harness 的对话流——是真的能看、能播、能拖进度条,不是一个文件名加一个字节数。
8
+
9
+ [English](README.md) · [中文](README.zh.md) · MIT
10
+
11
+ [![npm 版本](https://img.shields.io/npm/v/@crosery/dsh-viewer)](https://www.npmjs.com/package/@crosery/dsh-viewer)
12
+ [![npm 月下载量](https://img.shields.io/npm/dm/@crosery/dsh-viewer)](https://www.npmjs.com/package/@crosery/dsh-viewer)
13
+ [![Harness 兼容性](https://github.com/Crosery/dsh-viewer/actions/workflows/harness-compat.yml/badge.svg)](https://github.com/Crosery/dsh-viewer/actions/workflows/harness-compat.yml)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## 支持的格式:6 类 36 种扩展名
20
+
21
+ | 类别 | 渲染元素 | 扩展名 |
22
+ | --- | --- | --- |
23
+ | 图片 | `<img>` + 点击开灯箱 | `png` `jpg` `jpeg` `webp` `gif` `svg` `avif` `bmp` `ico` `apng` |
24
+ | 视频 | `<video controls>`,可拖进度条 | `mp4` `m4v` `webm` `ogv` `mov` |
25
+ | 音频 | `<audio controls>` | `mp3` `m4a` `aac` `wav` `flac` `ogg` `oga` `opus` |
26
+ | PDF | 内嵌阅读器 | `pdf` |
27
+ | 文档 | Host 转 PDF 后内嵌 | `docx` `doc` `rtf` `odt` `xlsx` `xls` `ods` `pptx` `ppt` `odp` |
28
+ | 网页 | sandbox `<iframe>` | `html` `htm` |
29
+
30
+ 其余格式仍然给一张带类型、大小和打开链接的卡片——**没有任何一种类别是渲染不出东西的**。
31
+
32
+ 视觉路由上,`png`/`jpg`/`jpeg`/`webp`/`gif` **同时**进入模型上下文:一次调用既给你看,也让模型看见。其余只给你看,卡片会如实标注。
33
+
34
+ ## 截图
35
+
36
+ 图片、视频、音频,一个回合内全部渲染:
37
+
38
+ ![图片、视频、音频](assets/screenshot-1-media.png)
39
+
40
+ Word / Excel / PowerPoint,Host 转换后内嵌:
41
+
42
+ ![Office 文档](assets/screenshot-2-office.png)
43
+
44
+ PDF 与本地网页,实时渲染:
45
+
46
+ ![PDF 与网页](assets/screenshot-3-pdf-html.png)
47
+
48
+ ## 安装
49
+
50
+ ```sh
51
+ dsh plugin --profile web add @crosery/dsh-viewer
52
+ ```
53
+
54
+ 然后把包名加进 profile `package.json` 的 `dsh.profile.bundles`,并**重启 profile**——bundle 成员变更不走热重载。
55
+
56
+ 仓库里直接提交了构建产物,所以这条命令不需要任何构建步骤,也不需要你在 profile 的 `allowBuilds` 里批准任何脚本。想要锁定版本安装的话,每次 release 也附带了同样的 tarball:
57
+
58
+ ```sh
59
+ dsh plugin --profile web add https://github.com/Crosery/dsh-viewer/releases/latest/download/dsh-viewer.tgz
60
+ ```
61
+
62
+ **桌面版**:打开**插件 → 添加插件**,粘贴上面那条 release 压缩包地址。`dsh` 命令行拒绝操作桌面 profile(`profile "desktop" is managed exclusively by the Electron application`),所以这是唯一的入口。在那里升级已装插件需要重启应用。
63
+
64
+ **harness 0.1.7(包括桌面版)需要 0.2.0 或更新的版本。** 从 0.1.7 起 harness 会自己检查插件的 peer 范围,而 v0.1.1 的范围止于 0.1.6 以下:安装会被拒绝,已经装好的副本在启动时被悄无声息地跳过——对话照常,但什么文件都不会显示。**harness 0.0.1 与 0.1.0-rc.2 – rc.7 需要 0.2.1 或更新的版本**:更早的范围从 0.1.0-rc.8 起算。那些版本自己不检查范围,但 npm 和 pnpm 会。
65
+
66
+ Office 渲染需要一个转换器。从 harness 0.1.6-alpha.2 起(包括桌面版),harness 自带的内置转换器就能处理 `doc` `docx` `xls` `xlsx` `ppt` `pptx`,什么都不用装。`rtf` 和三种 OpenDocument 格式,以及更早版本上的所有文档格式,仍需要 LibreOffice(`PATH` 上的 `soffice`,或 macOS 的应用包):
67
+
68
+ ```sh
69
+ brew install --cask libreoffice
70
+ ```
71
+
72
+ 没有转换器也不影响其余格式,文档卡片会明确说明缺什么。
73
+
74
+ 转好的 PDF 缓存在 `$DSH_HOME/.dsh-viewer-cache/`,所有 profile 共用,所以一份文档每改一次只转一次,而不是每显示一次转一次。缓存有上限:30 天没用过的产物会被删掉,超过 256 份或 512 MiB 时先删最久没用的。卡片加载它的 PDF 也算一次使用。
75
+
76
+ ---
77
+
78
+ ## 它解决什么
79
+
80
+ dsh 出厂只有 `read_image`:它存在的目的是把图片塞进**模型上下文**,所以
81
+
82
+ - 路由模型不声明 `image` 输入时,它直接拒绝;
83
+ - 只认 PNG/JPEG/WebP/GIF;
84
+ - 视频、音频、PDF、网页完全没有入口;
85
+ - 而且在 harness 0.1.2 及以前,**内置 Web 客户端不给它画卡片**——`read_image` 落在通用工具行上,人在屏幕前看到的只有一行 `Read image /path/to/a.png`。(0.1.3 起 harness 自带了 `read_image` 视图,本插件在这个 key 上的卡片会主动让位。)
86
+
87
+ 这个插件反过来:它存在的目的是把文件放到**用户屏幕上**。所以纯文本路由不是拒绝理由,非图片媒体也不是。
88
+
89
+ | | `read_image`(出厂) | `display_file`(本插件) |
90
+ | --- | --- | --- |
91
+ | 目的 | 进模型上下文 | 进用户视野 |
92
+ | 纯文本路由 | 拒绝 | 正常显示,只是模型看不见 |
93
+ | 图片 | PNG/JPEG/WebP/GIF | 上述 + SVG/AVIF/BMP/ICO/APNG |
94
+ | 视频 / 音频 | 无 | MP4/WebM/MOV/OGV,MP3/WAV/FLAC/OGG/M4A/Opus |
95
+ | PDF / 网页 | 无 | 内嵌 iframe |
96
+ | Office 全家桶 | 无 | docx/doc/rtf/odt、xlsx/xls/ods、pptx/ppt/odp(Host 转 PDF 后内嵌) |
97
+ | Web 卡片 | 0.1.2 及以前无(通用行);0.1.3 起有 | 有(在 harness 没有 `read_image` 视图的版本上,本插件也给它画卡片) |
98
+
99
+ ## 两条字节通道
100
+
101
+ 一个卡片可能从两个地方拿到字节,优先级如下。
102
+
103
+ **签名资源路由**(`/crosery/dsh-viewer/asset`)——Host 直接流式吐字节。这是唯一能承载视频/音频/PDF/网页的通道,也是唯一支持 Range 请求的通道,而 Range 正是 `<video>` 能出现进度条、Safari 肯开始播放的前提。
104
+
105
+ **持久化附件**——`ctx.attachments` 那条老通道。只有图片,但它在两种情况下不可替代:文件系统后端不暴露本地路径(远程 workspace)时,以及渲染出厂 `read_image` 结果时(那份结果只有附件,没有 URL)。
106
+
107
+ 两条通道不是冗余:视觉路由上的 PNG 仍然要走附件,因为那是它进入模型上下文的唯一方式。0.1.7 起持久化图片改由对话自带的加载器读取,同一会话里每张图只读一次。
108
+
109
+ 桌面版里,视频和音频从 Host 的回环地址(`__DSH_TRANSPORT__.streamBaseUrl`)加载,而不是窗口的 `dsh-app:` 源:应用的协议转发会去掉 `Content-Length`,Chromium 于是把媒体 URL 的第一次加载当成不可拖动的流。资源路由按签名授权,所以除了源以外什么都不变。
110
+
111
+ ## 资源路由的安全边界
112
+
113
+ 路由**从不接受浏览器给的路径**。工具执行时用一个每 harness 一份的密钥对已解析的绝对路径做 HMAC,路径和 MAC 一起放进 URL;路由只在 MAC 验过之后才认那个路径。
114
+
115
+ - 密钥:32 字节随机数,`$DSH_HOME/.dsh-viewer-asset-key`,0600,首次使用时生成。**自包含的签名而不是进程内 token 表**,是因为卡片必须活过重启:从会话日志重放出来的卡片手上只有几个月前铸的那个 URL。唯一的例外是转换过的文档:它的 URL 指向缓存里的 PDF,那份 PDF 被淘汰之后,卡片里的 frame 会得到 `404`,直到 `display_file` 重新转换这个文件。
116
+ - 篡改路径、换密钥、去掉签名、改用 padded base64 拼写——全部 404,且「没签名」和「签了但文件不存在」返回同一个状态码,探测者无法从状态码学到文件是否存在。
117
+ - `text/html` 和 `image/svg+xml` 带 CSP 响应头下发(前者 `sandbox`,后者 `default-src 'none'`)。同源直接导航到资源 URL 时,这两种文档否则会以应用的 origin 执行脚本。所有响应带 `nosniff`。
118
+ - 字节走 `createReadStream(processPath)` 而不是 `ctx.fs.readBytes`——后者会把整个文件读进内存,而两小时的视频正是这插件存在的理由。
119
+
120
+ ## Office 全家桶怎么转
121
+
122
+ 目标格式永远是 PDF。纯前端方案(`docx-preview` / `exceljs` + 表格网格)过不了本插件的约束:浏览器半边是 lazy-CJS bundle,模块表只答应 shell 基线,所有依赖都得内联,而 PPTX 根本没有可内联的免费渲染器。一个转换器产出一种格式,也意味着卡片只有一条文档代码路径。
123
+
124
+ - **转换器**:LibreOffice headless。PATH 上的 `soffice`/`libreoffice`,或 macOS 的 `/Applications/LibreOffice.app/...`;都没有时卡片如实说「需要安装 LibreOffice」并给出 `brew install --cask libreoffice`,**不失败整个调用**。
125
+ - **每次调用私有 profile**(`-env:UserInstallation=file://…`)。LibreOffice 多个实例共用用户 profile 目录会互相破坏;更要命的是**桌面上已经开着 LibreOffice 时,headless 调用会立刻退出且不产出任何文件**——这是「明明成功了却没有 PDF」最常见的原因。
126
+ - **串行队列**。私有 profile 解决了互相破坏,但没解决成本:几个 LibreOffice 冷启动同时跑会拖垮笔记本。重复由缓存吸收。
127
+ - **缓存键含 LibreOffice 版本** + 源文件 path/mtime/size。同样的字节经过新版 LibreOffice 是不同的产物,键里不带版本,升级后就会命中陈旧渲染而且看不出来。不对文件内容做摘要:几百 MB 的演示文稿不该为了「查一下转过没有」被读两遍。
128
+ - **harness 内置转换器的缓存键是它的配置**(字体、回退字体、图片分辨率等)加源文件的 path/版本/大小,而不是它的 `generation`:上游每个转换器实例都用 `randomUUID()` 生成 `generation`,拿它做键等于每次重启都是冷缓存,缓存永远命中不了、只会越长越大。配置变了就是新产物,重启不是。它看不到的是配置不变、随 harness 升级换了引擎的情况;这时的产物仍是同一份字节的忠实渲染,会像其他产物一样按时淘汰。
129
+ - 产物写在 `$DSH_HOME/.dsh-viewer-cache/`,**先以临时名写在缓存目录里,再 rename 就位**——读者要么看不到产物,要么看到完整的,不会拿到半个 PDF 喂给阅读器。临时文件必须和产物在同一目录:`rename` 不能跨文件系统,LibreOffice 的输出目录在系统临时目录下,而 Linux 上 `/tmp` 常是单独挂载的 tmpfs,直接从那里 rename 会报 `EXDEV`。所以 LibreOffice 的 PDF 是先复制进缓存目录再 rename。
130
+ - **缓存有界,按最近使用淘汰**:30 天没用、超过 256 份或超过 512 MiB 时,从最久没用的开始删;`display_file` 命中和资源路由下发 PDF 都算使用。插件启动时和每次写入后各在后台修剪一次,只动缓存目录里本插件写出的文件名的普通文件——别的文件、符号链接、子目录一概不碰。
131
+
132
+ 实测冷启动 2.6–3.6 秒,之后命中缓存。
133
+
134
+ ## 为什么把出厂 `read_image` 藏起来
135
+
136
+ `read_image` 和 `display_file` 只在一件事上重叠——把 raster 送进模型上下文——而同时看到这两个工具的模型会**两个都用**:先 `read_image`「我看一眼」,再 `display_file`「给你看」。真实会话里实测过:一张 1672×941 / 2.2 MB 的 PNG 在一个回合内**被吃进上下文两次**,对话流里出现两张长得一模一样的卡片。这不是提示词能可靠解决的问题,因为两个工具确实各自都在做模型以为它在做的事。
137
+
138
+ 所以其中一个不再对模型可见。`display_file` 是严格超集:它处理所有媒体、在纯文本路由上照常工作(`read_image` 在那里直接拒绝),并且在视觉路由上仍然把可接纳的 raster 送进上下文。藏掉它是**纯粹去重,不是去能力**。
139
+
140
+ 机制由注册表决定:`tools.restrict()` 明确拒绝 context-global 调用(「a context-global restriction would mask every agent」),所以限制是在**每个 agent 的 `agent.ctx`** 上施加的。
141
+
142
+ 两个触发点,缺一不可:
143
+
144
+ - `agent/created` —— 覆盖在工具已存在之后创建的 agent。这里的 `restrict` 必须被 try 包住:`read_image` 只在挂了附件服务时存在,`restrict` 对未知名字会抛错,而**一个抛错的 `agent/created` 监听器会否决整个 agent 的发布**。
145
+ - `tools/change` —— 覆盖反过来的竞态。`read_image` 注册在 `dsh-tool-fs` 的异步 `attachments` 注入里,先创建的 agent 会**终生**看得见它。已经加上限制的 agent 会跳过,所以重试是幂等的;等待列表用 `WeakRef` 持有,不会把 agent 留活。
146
+
147
+ **这条改动需要重启 profile 才生效**——它在 Host 半边的 bundle 里,不走热重载。
148
+
149
+ 卡片表头也不再一律叫「图片」:`read_image` 的卡片叫「读入图片」,这样万一有人关掉这个开关,两种来源在对话流里仍然一眼能分开。
150
+
151
+ ## `read` 打到媒体上:不报错,也不返回垃圾
152
+
153
+ 出厂 `read` 是 UTF-8 解码。更关键的是:**出厂的 `dsh-fs-local` 会采样文件头,遇到 NUL 字节直接抛 `FS_NOT_TEXT`(`cannot read "…": binary file`)**。所以 `read` 指向 PNG 时,**无论有没有这个插件,都会在对话流里画一条红色失败行**——而那个文件明明存在、可读、离上屏只差一次工具调用。
154
+
155
+ 两种「显而易见」的纠正都去不掉那条红行:`tools/pre-execute` 拒绝会自己造一个 `isError`;`tools/post-execute` 也不行——替换 value 在失败结果上被明确拒绝,替换 content 又保留 `isError`。
156
+
157
+ 所以纠正放在 **`tools/execute`(around-dispatch)**,并且**不调用 `next()`**:注定失败的读取根本不发生,一次文件 I/O 都没有。返回的自造结果会经过 `normalizeDispatchResult`,对成功结果它会**用本插件给的 value 重跑该工具自己的 `output.render` 和 `output.presentationMeta`**——于是持久化的 read 元数据也被替换掉,出厂 read 卡片照常渲染,只不过内容是一行指向 `display_file` 的说明,而且是一次**普通的成功读取**。
158
+
159
+ 配套还有一段系统提示词,紧跟出厂 `tool:read` 的指引:0.1.1 及以前是 order 101(出厂是 100),0.1.2 起取 `getSectionOrder('TOOL_READ') + 1`。当前 agent 调不到 `display_file` 时这段为空;只有 `redirectRead` 打开时它才提到 `read` 会被引回 `display_file`;在提供 `present` 的版本上,它把交付物让给 `present`、把答案里的配图让给 markdown 图片,避免同一个文件被展示、内嵌、交付三遍。`.html` **不在**纠正范围内:读 HTML 源码是正当的文本读取,显示它是另一个意图。
160
+
161
+ ## 完成的轮次仍把展示过的文件留在屏幕上
162
+
163
+ 从 0.1.6 起,对话会把一个已完成轮次的工具行(包括本插件的卡片)收进一条「用时 N 秒」的折叠行,0.1.7 起这是默认行为。一个存在的意义就是把文件摆到用户眼前的插件,答案一出来卡片就被收起来了。所以插件同时往对话的轮次尾部(turn tail)贡献一项——就是轮次收尾回复和操作行之间那一栏,harness 自己的交付文件卡片也放在那里——在轮次完成后把这一轮展示过的文件显示在那里。从 `run_code` 里展示的文件也算:这类调用记成 `tool/ptc-dispatch` 事件,不带轮次号,所以在轮次结束时按对话引擎自己给它们定的位置收集。凡是工具行本来就看得见的地方,尾部都不出现:`verbose` 对话视图,仍在运行、被中止或出错的轮次,以及在 0.1.7 上用户在运行途中插话(steer)过的轮次——0.1.7 的对话不会折叠这种轮次。
164
+
165
+ ## 配置
166
+
167
+ 四个开关,默认值都是「插件该有的行为」:
168
+
169
+ | 字段 | 默认 | 关掉之后 |
170
+ | --- | --- | --- |
171
+ | `tool` | `true` | 不注册 `display_file`(它会从模型看到的 schema 列表里消失,而不是留在那里拒绝调用) |
172
+ | `redirectRead` | `true` | `read` 可以自由地把 raster 解码成替换字符 |
173
+ | `feedModel` | `true` | 图片只上屏,永不进模型上下文——图给人看而不是给模型看时更省 token |
174
+ | `supersedeReadImage` | `true` | 出厂 `read_image` 重新对模型可见(于是又可能出现同一张图进两次上下文) |
175
+
176
+ 在哪里改取决于 harness 版本:
177
+
178
+ - **0.1.6 及以前**:改 `$DSH_HOME/settings.yaml` 的 `crosery-viewer` 段,热重载,不需要重启。
179
+ - **0.1.7 起**(web 与桌面版):本插件**没有设置表单**。设置页只为插件标成 volatile 的字段生成表单,而这几个字段都不是。请在 profile 自己的 patch 文件 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 里给插件条目 `viewer` 写配置——`dsh --profile web` 用 `~/.dsh/profiles/web/cordis.patch.yml`,桌面版用 `~/.dsh/profiles/desktop/cordis.patch.yml`——然后重启该 profile 或桌面应用:
180
+
181
+ ```yaml
182
+ - id: viewer
183
+ config:
184
+ redirectRead: false
185
+ ```
186
+
187
+ 这一行会**整体替换**条目的 `config`,而不是与之合并,所以没写的字段保持默认值。0.1.7 上 `settings.yaml` 已被移除,它的一次性导入把每一段写进同 id 的条目,所以 `crosery-viewer` 段**不会**被带过来——它只留在 `settings.yaml.imported` 里。
188
+
189
+ ## 装到 profile
190
+
191
+ ```sh
192
+ dsh plugin --profile web add github:crosery/dsh-viewer # 或本地开发:link:
193
+ ```
194
+
195
+ 然后在 profile `package.json` 的 `dsh.profile.bundles` 里加上包名。**bundle 成员变更必须重启 profile**(`cordis.patch.yml` 的编辑才走热重载)。
196
+
197
+ 本地开发时客户端半边有个坑:装的 `dsh-client-modules@0.1.1-rc.2` 用 `require.resolve(spec + "/package.json")` 解析清单,**只认包名不认文件绝对路径**。所以 `--patch` 里写 `name: /abs/path/lib/index.js` 只会加载 Host 半边,客户端 bundle 静默不进 boot 图。让包能按名字解析(`link:` 或 symlink 进 profile 的 `node_modules`),patch 行写包名。
198
+
199
+ ## 验证
200
+
201
+ ```sh
202
+ npm run typecheck # 两个 program 分开检查
203
+ npm run build # 两份 .d.ts + 两个 bundle(产物提交进仓库,改动后要一并提交)
204
+ npm run check # 仓库不变式(README 计数、locale 键、peer 范围、安装路径)
205
+ npm run check:dist # 提交的 lib/ 与重新构建逐字节一致
206
+ npm test # 147 个用例
207
+ ```
208
+
209
+ v0.1.1 时在真实环境跑通(`dsh 0.1.1-rc.2`,Node 26.7.0,claude-sonnet-5 路由,headless Chrome 驱动;0.2.0 在 0.1.7-rc.2 的 Web 与桌面版上的实测见 [docs/acceptance.zh.md](docs/acceptance.zh.md)):
210
+
211
+ - **七种卡片全部渲染**:图片(`<img>` 原尺寸,点击开灯箱、Esc 关闭)、视频(`<video>` 有进度条,`currentTime = 4` 跳转成功,`seekable.end = 6`)、音频(`<audio>`,duration 5)、PDF、**文档(docx / xlsx / pptx)**、网页、通用文件。
212
+ - **Office 三件套真的转出来了**:三个 iframe 的 src 全部返回 `200 application/pdf` 且以 `%PDF-` 开头,frame 内部含 Chrome PDF 阅读器的 `<embed>`;pptx 显示为 1/3 页并带幻灯片缩略图侧栏,正文是真实的幻灯片内容。
213
+ - **全部默认展开**(`aria-expanded="true"`),不再有「只有标题、看不到内容」的卡片。
214
+ - **徽标正确**:视觉路由上的 PNG 显示「已进入模型上下文」,且模型确实描述出了「64×48 红色图片」;其余显示「仅在页面显示」。
215
+ - **重放安全**:整页 reload 之后卡片全部从持久化的 `tool/result` meta 重建。
216
+ - **一次调用只进一次上下文**:模型可见工具里已无 `read_image`(问它「你能调用 read_image 吗」答「不能」);一次 `display_file` 的会话日志里恰好一个 image content block。
217
+ - **`已进入模型上下文` 是真的**:给模型一个名字毫无提示的 `asset-0417.png`(蓝底黄色大数字 7),它答出「数字 7、背景蓝色、图形黄色」——只有真看到才答得出。
218
+ - **`read` 打到图片上不再报错**:会话日志里那条 `tool/result` 的 `isError` 不存在(成功),`meta` 是本插件替换后的单行 `FsReadMeta`;页面上是一条普通的 `Read · /tmp/…/red.png` 行,整页搜不到 `Error:`。
219
+ - **嵌套 `run_code` 调用有内容**:`# Code · Display page.html via display_file tool` 的子行下面,iframe 内真的渲染出了页面正文,且该 frame 仍带 `sandbox="allow-scripts allow-forms allow-popups"`。
220
+ - **资源路由**:`Range: bytes=4-9` → 206 + 精确 6 字节;`bytes=-4` → 尾部;`bytes=999999-` → 416 + `bytes */25515`;伪造签名、把路径换到 `/etc/passwd`、无签名 → 全部 404;POST → 405。
221
+
222
+ `tests/asset-route.test.ts` 把 handler 挂在真的 `node:http` 上跑真的请求:Range 的正确性无法靠解析器单测证明,必须看响应到底带了哪些字节和头。`tests/convert.test.ts` 用 LibreOffice 自己造 DOCX 再转回 PDF,断言产物头四字节是 `%PDF-`,并验证二次调用命中缓存不重写产物。
223
+
224
+ **两份 tsconfig 是必需的,不是洁癖。** 两个半边都在增强同一个 `@deepseek-ai/cordis` 的 `Context`,而 `sessions` 在 Host 侧是 `SessionStore`、在浏览器侧是 `ISessions`。一个同时看见两份增强的 program 会静默解析到错的那个(`skipLibCheck` 把冲突盖掉了),表现为 `ctx.sessions.binding` 不存在。
225
+
226
+ **`inject` 里不要写 `webServer`。** 未激活的条目是**硬启动失败**(`dsh: 1 entry did not activate`),不是优雅跳过——写进 `inject` 会让这个 bundle 无法与 `dsh-headless`/`acp` 组合,而不只是在那里失效。路由挂在 `ctx.inject(['webServer'], …)` 的嵌套作用域里,没有 HTTP 服务时工具照常注册,只是不铸 assetUrl。
227
+
228
+ ## 两个只有真跑才会发现的坑
229
+
230
+ **嵌套 `run_code` 调用拿不到 `presentationMeta`。** 注册表只为顶层调用投影(`exec.parent === undefined`),所以模型在 `run_code` 里调 `tools.display_file` 时,卡片手上什么元数据都没有,只剩一行光秃秃的表头。修法是让模型可见的信封多带三个结构化字段(`<media>` / `<bytes>` / `<asset>`),meta 缺失时从 content 里的信封恢复——content 对嵌套调用同样会持久化。这是卡片解析**自己写的结构化信封**,不是从散文里抠 id;恢复出来的值仍然走和重放路径完全相同的校验,包括「assetUrl 必须是本插件路由」那道门。
231
+
232
+ **PDF 的 iframe 不能加 `sandbox`。** 不带 `allow-same-origin` 的 sandbox 给 frame 一个不透明 origin,而 Chrome 内置 PDF 阅读器拒绝在那里运行——frame 里显示的是「此页面已被 Chrome 屏蔽」而不是文档。PDF 本来也不需要 sandbox:它以 `application/pdf` + `nosniff` 下发,浏览器交给自己隔离的阅读器,不会在本 origin 执行任何东西。本地 HTML 正相反——那是 agent 可能刚写出来的任意脚本——所以它保留不透明 origin,并由 Host 随响应下发的 `sandbox` CSP 兜底。
233
+
234
+ ## Harness 版本兼容
235
+
236
+ | 在哪 | 版本 | 证据 |
237
+ | --- | --- | --- |
238
+ | Web(`dsh --profile web`) | 每一个已发布版本,从 `0.0.1-rc.1` 到 `0.2.2-alpha.2`,包括 npm 的 `latest`(`0.2.1-rc.5`)、`next`(`0.2.1-rc.5`)和 `alpha`(`0.2.2-alpha.2`)。`0.0.1-rc.1` 与 `rc.2` 也被接纳,但它们自己的 harness 谁都装不上 | 早期版本的历史扫描与冒烟见验收记录;本次更新于 2026-11-01 UTC 在 `0.2.1-rc.5` 和 `0.2.2-alpha.2` 上分别通过两份类型检查、209/209 测试与严格九阶段冒烟。CI 每周扫描和 release 门禁覆盖全部 38 个可安装版本;`0.0.1-rc.5`、`0.1.0-rc.2`、`0.1.0-rc.6`、`0.1.7-rc.2` 与 `0.2.1-alpha.1` 上的浏览器实测(v0.1.1 时也在 `0.1.1-rc.2` 与 `0.1.5-rc.2` 上实测过) |
239
+ | 桌面版 | 当前 `nightly` 更新源:`0.2.1-rc.5`(2026-10-29 发布) | 同版本 npm Web 运行时严格冒烟通过;当前桌面版字节等待 release 门禁验证。历史桌面运行时冒烟与 macOS 窗口实测:`0.1.7-rc.2` |
240
+
241
+ peer 范围恰好接纳上面这些支持,每个元组一组比较器,无论按 npm 的 semver 规则,还是按 harness 从 0.1.7 起自己采用的「包含预发布」规则。`0.1.0-rc.8` 之前的版本前端更老,没有 `dsh-client-ui-renderer`;本插件只需要其中的 `slots` 和 `locale` 两个服务,每个版本都有。`0.1.8`、`0.2.3` 等未验证元组等 CI 验证过才接纳。
242
+
243
+ CI 负责让这份声明保持真实。每个 pull request 都会在钉住的版本、`0.1.1-rc.2` 最低线和桌面版当天分发的版本上,跑类型检查、测试、peer 接纳检查,以及打包后插件的真实启动,并在无头 Chrome 里把它跑起来。每天的任务对桌面版和 npm 的 `latest`、`next`、`alpha` 重复这些检查,并在 macOS 上对桌面版压缩包本身再跑一次冒烟。每周的任务扫描每一个已发布的 harness 版本,在其中每个能装上的版本上启动并运行本插件,所以新版本不需要任何人手动添加就会被测到。失败会开一个 `upstream-drift` issue,第一次全绿的运行会把它关掉。发版也以同样的检查为门禁。详见 [docs/harness-compatibility.zh.md](docs/harness-compatibility.zh.md)。
244
+
245
+ ## 已知限制
246
+
247
+ - **只接受本地文件路径**,不接受 URL。规范值的形状留了扩展位,但 v1 没做。
248
+ - **视频/音频的时长和分辨率不在卡片头部**——那需要 ffprobe。`<video>` 元素自己会显示。
249
+ - **插件自己读取只有附件的图片时(0.1.5 及以前),对象 URL 要到插件卸载才回收**。上界是这期间显示过的不同附件数量,与出厂对话图库按会话持有的上界同量级。0.1.7 起这些图片由对话自带的加载器持有。
250
+ - 远程 workspace(后端不提供 `processPath`)上,非图片媒体没有可用通道,卡片如实显示「此文件系统后端不提供可预览的本地路径」。
251
+ - 未做视频转码:浏览器放不了的编码(例如 `.mov` 里的 ProRes)会落到 `<video>` 的降级文案上。
252
+ - 桌面版里「在新标签打开」换成**「在侧边栏预览」**(harness 自带的预览,PDF 用 PDF.js):桌面窗口会静默拒绝指向应用 URL 的新标签。卡片里的内容——图片、播放器、PDF 与文档内嵌框——与 web 上完全一样。
253
+ - **轮次尾部需要 0.1.6 起的列表形态。** 在 0.1.2–0.1.5 上,折叠(`compact`)后的已完成轮次会把卡片留在折叠区里;想让它们保持展开,把对话视图切到 `normal`。在 0.1.6 的 `normal` 视图下,卡片会同时出现在轮次里和尾部。
254
+ - 用户展开一个已折叠的轮次时,其中展示过的文件会出现两次——工具行里一次、尾部一次——与 harness 自己的交付文件卡片相同。
255
+ - 尾部靠对话自己的过程锚点(它的 `turn-process` 轮次数据)识别插话过的轮次。如果将来的对话不再发布这份数据,插话过的轮次会把卡片显示两次——展开的工具行里一次、尾部一次——而不是丢掉它们。
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,29 @@
1
+ # Bundle patch layer for @crosery/dsh-viewer.
2
+ #
3
+ # One row. It loads the package's main entry as the Host half (an ordinary
4
+ # Cordis plugin); the `dsh.client` declaration in package.json is what makes
5
+ # the web app also load the ./client bundle as the browser half.
6
+ #
7
+ # The row carries no config, so every setting keeps its schema default: the
8
+ # `display_file` tool is registered, `read` on opaque media is redirected to it,
9
+ # and an image-capable route also receives admissible rasters in context.
10
+ #
11
+ # Where the preferences are edited depends on the harness train. Up to 0.1.6
12
+ # they live in the `crosery-viewer` settings namespace, editable in
13
+ # $DSH_HOME/settings.yaml without a restart. From 0.1.7 settings.yaml is gone
14
+ # and there is no settings form for this plugin: the Settings page builds forms
15
+ # only from volatile fields, and these are not. Set them on the `viewer` entry
16
+ # in the profile's own patch file, $DSH_HOME/profiles/<profile>/cordis.patch.yml
17
+ # (the desktop app's profile is `desktop`), which applies after this layer. A
18
+ # patch replaces the entry's config as a whole rather than merging into it, so
19
+ # a field it leaves out keeps its default:
20
+ #
21
+ # - id: viewer
22
+ # config:
23
+ # redirectRead: false
24
+ #
25
+ # 0.1.7's one-time import of settings.yaml moves each section into the entry of
26
+ # the same id, so the old `crosery-viewer` section is NOT carried over.
27
+ - insert:
28
+ - id: viewer
29
+ name: '@crosery/dsh-viewer'