# 开发方案 v3:三位置图片路径缩略图(需求重定义 + 标准化修订) > **状态:待用户确认需求与文档内容,未进入开发。** > **与 v2 的关系**:`docs/PLAN.md`(v2)= 安全修复基线(7 项缺陷逐项修复,保留不动);v3 = 范围扩展(输入框①/发送框②)+ 白名单可移植性修订。安全基线继承 v2,本文只写增量。 > **日期**:2026-08-29。**续作入口**:本文件 + `../DevTaskOverview.md` + `../../ProjectOverview.md`。 --- ## 1. 需求(待用户确认) **用户原话(2026-08-29)**: > "我的需求是在文本输入框、文本发送框、LLM输出框(已实现)如果用户粘贴的是图片(或文件路径为),应该显示缩略图(如原图尺寸太大),……这个是DSH插件,安装应该遵循DSH的标准,而不是换了另外的文件目录或工作空间就不能使用" **我的理解**(请逐条纠错): | 位置 | 理解(Q1/Q2 已于 2026-08-29 确认) | 现状 | |---|---|---| | ① 文本输入框 | Web GUI 聊天输入框(composer):正在输入时,草稿里出现**图片格式文件路径** → 输入框**上方显示图片预览行**(展示尺寸随设置) | ❌ 只有路径文字(modlens 粘贴时插入的) | | ② 文本发送框 | **发送后的用户消息气泡**:消息里的图片路径 → **直接显示图片,与 LLM 输出一致**(Q1 确认) | ❌ 路径文字 | | ③ LLM 输出框 | 助手回复正文里的图片路径 → 内联渲染图片 | ✅ 已实现(当前依赖环境变量启动才能覆盖粘贴图) | **需求本质(Q2 确认)**:"缩略图"不是生成缩略图文件,而是**展示尺寸控制**——防止大尺寸原图占据整个会话框。插件提供**展示尺寸大/中/小三档设置(有默认档)**,三处统一生效;图片本体始终是原图,只改展示上限(CSS,见 §5.1)。 **触发条件**:粘贴图片(modlens 转路径)或手动输入路径,只要是 9 格式(png/jpg/jpeg/webp/gif/svg/avif/bmp/ico)的图片文件路径即触发。 **硬约束(用户批评,必须满足)**: 1. **标准 DSH 插件**——`dsh plugin --profile web add` 装完即用,不靠环境变量、不靠自定义启动脚本 2. **换目录/换工作区不能失效**——白名单不得绑定单一会话 workspace 3. 安全是前提:唯一文件读取执行点 = host 路由 handler(继承 v2 全部安全不变量,见 §8) **社区版对照(用户提问)**:`dsh-plugin-tasks/dsh-inline-images` **没有**实现此功能——它只做 ③(助手流改写)+ 点击放大;①② 零涉及(快照 `source/src/index.ts:231` 仅 `llm/stream` 包装;审计报告全文无输入框/发送侧内容)。 ### 确认点记录(2026-08-29 用户答复) | # | 确认点 | 结果 | |---|---|---| | Q1 | "文本发送框" = 发送后的**用户消息气泡**,发送之后像 LLM 输出一样直接显示图片 | ✅ **已确认** | | Q2 | 缩略图本质 = 展示尺寸控制(防大图占满会话框);插件支持**大/中/小三档尺寸设置**(有默认档),不一定生成缩略图文件 | ✅ **已确认** → 设计见 §5.1 + §3.6(设置卡片);① 预览位置 = 输入框上方整行(DSH 标准 slot 座;框内嵌 `` 架构上不可能,产品输入框是 ``,见 §3.1) | | Q3 | 插件设置中增加**图片目录安全白名单(可添加多个)**,由用户自行决定开放目录 | ✅ **已确认**(2026-08-29)→ settings `extraRoots: string[]` 多目录,设置卡片内增删;默认根(workspace + 粘贴根)不可移除,追加根全部用户自管 | --- ## 2. v1/v2 方案偏差检讨(为什么需要 v3) | # | 位置 | 偏差 | v3 修订 | |---|---|---|---| | 1 | PLAN-v2 §1/§4 | "v1 = 纯 host 插件"只覆盖 ③ | 需求改三位置缩略图;v1 = host 半 + 客户端 half(纯展示) | | 2 | PLAN-v2 §4/§6 | 白名单 = 会话 workspace(或环境变量)——换工作区失效、粘贴图覆盖不到,即用户批评的"非标准" | 多根白名单 + 标准 settings(§5) | | 3 | DevTaskOverview §6 M4 记录 | 把"粘贴图在 workspace 外"当"设计边界",临时解法 = `TU4_INLINE_ALLOWED_ROOT` 环境变量 + 自造 `start-dsh-web.ps1` | 环境变量方案**废弃**;`start-dsh-web.ps1` 已删除;多根默认是需求驱动的正解 | | 4 | PLAN-v2 §8 | 客户端 half 整体推迟到 v2(M7 灯箱) | 客户端 half 提前进 v1(①标准 slot + ②DOM 后处理,纯展示);灯箱留 v2(M9) | | 5 | DevTaskOverview 决策速记 | "v1 纯 host,不声明 dsh.client" | "v1 = host(读取/守护/流改写/路由)+ 客户端 half(①②纯展示);路由 handler 仍是唯一执行点" | | 6 | DevTaskOverview §6.1 | 验收步骤依赖环境变量启动 | 重写:标准安装 → 任意工作区验收 ①②③ + 换工作区复验 | | 7 | (新发现) | `dsh.plugin.json` 被当作"插件清单"——**它不是 DSH 清单**(全 DSH 源码零引用) | 权威清单 = package.json 的 `dsh.bundle.patch`/`dsh.client`/exports;`dsh.plugin.json` 保留仅为兼容社区/dsh-plugin.app 惯例 | --- ## 3. 机制核实记录(全部带 DSH 源码行号,2026-08-29 逐条核实) DSH 源码事实源:`D:\WorkStudio\MyAI\DeepSeek-Harness\deepseek-harness\` ### 3.1 输入框 ① = 标准 slot `conversation.input.dock`(零 DOM 事件监听) - **slot 声明**:`packages/client/ui-conversation/src/client/contract/slots.ts:205` ```ts 'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone } ``` 官方定位(same file, line 209-214):"Anything needing its own line above the card belongs in `conversation.input.dock`"——**输入框卡片上方的整行,正是缩略图行的标准座位**。 - **对插件开放**:DSH 自带插件 slot 开发目录 `packages/extensions/cordis-client-runner/src/client/slot-catalog.ts:602`,注册范式(原文示例, line 653): ```js return { inject: ['slots'], apply(ctx) { ctx.slots.inject('conversation.input.dock', () => ctx.slots.register( { name: 'conversation.input.dock', id: 'my-entry', order: 100, label: 'My entry' }, () => React.createElement('div', null, 'hello'), )) }, } ``` - **草稿文本零监听获取**:`slots.ts:334-337` `InputZone { session: ConversationSnapshot; input: InputState }`;`packages/client/ui-conversation/src/client/input/contract.ts:213-228` `InputState.draft: string`。slot 条目接收 owner 点时快照,**骨架在 store 变化时自动重渲,条目无需订阅**(slots.ts:329-332 注释)。→ 每次击键草稿自动到达渲染函数,无 textarea 事件监听、无 DOM 锚定。 - **空态零占位**:list slot 无条目/条目返回 null 时不渲染任何内容(与 `conversation.input.plan` 的 "Unoccupied, the seat renders nothing" 同契约)。 ### 3.2 发送框 ② = 客户端 DOM 后处理(插件侧唯一途径) - `conversation.chat.node`(消息节点渲染器,keyed slot)的 key 表**全部 15 种已被占用**(含 `user`):`slot-catalog.ts:235` "already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, **user**, workflow-run"。 - `conversation.message.images`(single)被核心 `ui-attachment` 占用:`packages/client/ui-attachment/src/client/index.ts:12-17`(同时占 `conversation.input.attachments`)。 - host 侧亦无用户消息内容钩子:事件面只有 `llm/stream`(助手生成)、`session/event`(只读观察)等,无用户消息改写点。 - **结论**:② 的插件级唯一方案 = 客户端 half **保守 DOM 后处理**: - MutationObserver 观察会话区;仅当**单个文本节点内含完整图片路径**时处理(跨节点不处理,记录为边界) - 跳过 `/` 内的路径(代码块保持原样) - 替换为 ``;防重入标记;React 重渲染产生新节点 → observer 自动再处理 - `onerror`(路由 404/413)→ 还原路径文字 - **最坏情况 = 缩略图不出现、路径文字仍在,不破坏任何显示**;不改消息数据、不改草稿 ### 3.3 客户端 half 标准声明 - **package.json 字段**(权威格式,活体模板 = 本机 `@liustack/modlens` 的 package.json): ```json "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "inject": ["slots"], "platform": "web", "immediately": true } }, "exports": { ".": "./lib/index.js", "./client": "./lib/client.js" } ``` - **强制校验**:`packages/client/modules/src/index.ts:453`——声明 `dsh.client` 但 exports 无 `./client` 时 throw;`:459` `immediately: true` = 启动期预取。 - **bundle 顶层契约**:经典脚本必须调 `window.__ModuleLoader__.load({ id, factory })`,factory 接收模块表 `require`、返回 entry 导出(`packages/client/modules` + `tsdown.client.ts:562` banner 形态)。活体模板 `@liustack/modlens/dsh/client.js`:手写 lazy-CJS(`var module = {exports:{}}` 起手,`return exports` 收尾),零构建依赖。 - **我们的构建方式**:esbuild `platform: browser, format: cjs`,banner 注入 `window.__ModuleLoader__.load({ id: 'dsh-tu4-inline-images', factory: (require) => { var module = { exports: {} }, exports = module.exports;`,footer 注入 `return module.exports; }})`——与 tsdown.client.ts:562 同形(实现时对照 tsdown.client.ts 原文核对)。 - **React 获取方式**:slot-catalog 示例在 render 函数内直接用 `React.createElement`(无 import)→ 实现时先做 spike 验证 factory 作用域内 React 的提供方式(模块表 require 或 loader 注入),10 分钟前置项。 ### 3.4 白名单追加根 = 标准 settings 配置 - **机制**:`ctx.settings.register(namespace, zodSchema, { base? })`(settings 服务;活体模板 = modlens host 半注册 `'modlens'` 命名空间)。schema 的 zod 源 = `@deepseek-ai/schemastery`(DSH 核心同款,`packages/bundle/web-app/src/index.ts:19`)。 - **降级预案**:若 profile 运行时解析不到 schemastery → 手工校验 `extraRoots: string[]`(绝对路径数组)。实现时先验证。 ### 3.5 客户端取 token = 同源只读 `GET /state` - 路由 `GET /plugins/dsh-tu4-inline-images/state` → `{ "token": "<64-hex>" }`(`no-store`,只返回 token,无其他数据)。 - **信任模型论证**:token 本就存在于 DOM(③ 渲染出的 `` 含 token);暴露给同源客户端 JS 不扩大攻击面;与 modlens paste 路由(浏览器直调 `/modlens/paste`)同信任模型。token 仍是唯一门禁(继承 v2:响应 `no-store`/`nosniff`,SVG 加 `CSP: sandbox`,错误零回显)。 ### 3.6 标准设置卡片(GUI 设置界面,尺寸设置入口) - **slot `settings.plugin.item`**(keyed,scope root,**key 完全开放**):`slot-catalog.ts:1396-1417`("One plugin's card inside the plugin configuration section";keyDomain = "open: any string the owner dispatches... none are taken yet";现有租户 BashCard/AgentLoopCard/WebSearchCard 各用自己的 key,互不冲突)。 - **活体模板**:`@liustack/modlens` 客户端 `dsh/client.js:950-951`——`ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', id: 'modlens', key: 'modlens', order: 30 }, Card))`,Card 为 React 组件(设置卡片 UI)。 - **我们**:客户端 half 以 key `dsh-tu4-inline-images` 注册卡片 → GUI 设置界面"插件配置"区出现本插件卡片,内容 = **尺寸档位选择(大/中/小)** + 白名单追加目录(Q3 若确认)。数据面 = §3.4 的 settings 命名空间;UI 面 = 本卡片。**标准插件配置面 = settings 命名空间(数据)+ settings.plugin.item(卡片 UI)**。 ### 3.7 `dsh.plugin.json` 非 DSH 清单 - 全 DSH 源码 `packages/` grep `dsh.plugin.json` = **零引用**。DSH 实际读取:package.json 的 `dsh.bundle.patch`(指向 cordis.patch.yml)+ `dsh.client`(客户端 half)+ `exports`(入口解析)。 - `dsh.plugin.json` 是社区/dsh-plugin.app 的惯例(社区版自带该文件)。**保留该文件**仅为生态兼容;权威字段一律在 package.json。 --- ## 4. 架构(修订后) ``` host 半(现有,改白名单) 客户端 half(新增,纯展示,零 fs) ┌──────────────────────────────────┐ ┌──────────────────────────────────────┐ │ 图片路由 /image │◄───────│ ① conversation.input.dock 标准 slot │ │ token + 多根守护 + 限流读取 │ img │ (输入框上方整行;owner 快照 │ │ llm/stream 改写(③,已实现) │ src │ InputState.draft 自动重渲) │ │ tapIndex 尺寸 CSS(640×420,已实现) │ │ ② 用户消息气泡 DOM 后处理(保守策略) │ │ settings 命名空间 extraRoots(新) │ │ ③ 的 由产品 MarkdownText 渲染 │ │ GET /state → {token}(新,同源只读) │───────►│ 缩略图 src = 路由 URL │ └──────────────────────────────────┘ │ onerror → 撤图/还原文字(路由 404/413) │ 唯一文件读取执行点 = 路由 handler └──────────────────────────────────────┘ 客户端只发请求:语法匹配 ≠ 放行,加载失败即撤图 ``` - **① 预览行**:输入框上方整行(标准 slot,§3.1);图片展示上限随 `size` 档位(§5.1),行高自适应;常量 CSS 经 `document.createElement('style')` 注入(DSH 客户端惯例);无图片路径 → 渲染 null(零占位);**纯展示:无删除按钮、不改草稿**(路径文字必须保留——文本模型靠路径工作,发送行为不变)。 - **② 用户气泡**:见 §3.2 保守策略;**直接显示图片,与 LLM 输出一致**(Q1 确认);展示上限同 `size` 档位(客户端常量 CSS)。 - **不动 modlens**:粘贴 → 路径的插入行为保持(modlens 职责);我们只负责"路径出现 → 缩略图"。 ## 5. 白名单修订设计(多根,标准,可移植) | 根 | 来源 | 说明 | |---|---|---| | 会话 workspace | fs 服务 `config.cwd` | LLM 在项目内引用的图片(原有) | | modlens 粘贴根 | `join(os.tmpdir(), 'modlens-dsh-paste')` | DSH 粘贴图**标准落盘点**(modlens `pasteRoot()` = `join(tmpdir(), 'modlens-dsh-paste')`,modlens `dsh/index.js`);modlens 未安装时该根解析失败 → **静默跳过**,天然解耦 | | 追加根(用户配置) | `ctx.settings.register('dsh-tu4-inline-images', { extraRoots: string[] })` | 标准 settings,持久化,热更新(settings 变更 → 重解析根 → 更新 handler/rewrite deps) | - **守护算法不变**(v2):净化 → `fs.resolve`(realpath)→ 任一根 `fs.contains(rootTarget, target)` 通过 → stat 常规文件;多根 = 任一根包含即放行。 - **废弃**:`TU4_INLINE_ALLOWED_ROOT` 环境变量(代码中移除解析逻辑);`start-dsh-web.ps1` 已删除。 - **验收语义**:标准安装 + 任意工作区 + 无环境变量 → ①②③ 全工作。 ### 5.1 展示尺寸设置(Q2 确认:大/中/小三档,默认档) - **设置面**:settings 命名空间 `dsh-tu4-inline-images` = `{ size: 'small' | 'medium' | 'large' (默认 'medium'), extraRoots: string[] (Q3 已确认) }`;UI 入口 = §3.6 设置卡片(GUI 内切换,不碰代码/环境变量)。 - **档位值**(代码常量,默认值,可调): | 档 | 展示上限(max-width × max-height) | 说明 | |---|---|---| | small 小 | 320 × 240 | 紧凑预览 | | medium 中(默认) | 640 × 420 | 与当前 tapIndex 值一致(③ 现有行为不变) | | large 大 | 960 × 600 | 尽量大,仍受会话框约束 | - **CSS 形态**:`max-width + max-height + width:auto; height:auto`——**保持宽高比,只设上限,不强制拉伸**(与原图尺寸解耦,即"不一定生成缩略图")。 - **三处统一应用同一设置**: - ③ host:`tapIndex` 常量 CSS;**settings 变更 → dispose + 重新注册 tapIndex**(新常量),即时生效 - ①② client:客户端注入的 `
/` 内的路径(代码块保持原样) - 替换为 ``;防重入标记;React 重渲染产生新节点 → observer 自动再处理 - `onerror`(路由 404/413)→ 还原路径文字 - **最坏情况 = 缩略图不出现、路径文字仍在,不破坏任何显示**;不改消息数据、不改草稿 ### 3.3 客户端 half 标准声明 - **package.json 字段**(权威格式,活体模板 = 本机 `@liustack/modlens` 的 package.json): ```json "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "inject": ["slots"], "platform": "web", "immediately": true } }, "exports": { ".": "./lib/index.js", "./client": "./lib/client.js" } ``` - **强制校验**:`packages/client/modules/src/index.ts:453`——声明 `dsh.client` 但 exports 无 `./client` 时 throw;`:459` `immediately: true` = 启动期预取。 - **bundle 顶层契约**:经典脚本必须调 `window.__ModuleLoader__.load({ id, factory })`,factory 接收模块表 `require`、返回 entry 导出(`packages/client/modules` + `tsdown.client.ts:562` banner 形态)。活体模板 `@liustack/modlens/dsh/client.js`:手写 lazy-CJS(`var module = {exports:{}}` 起手,`return exports` 收尾),零构建依赖。 - **我们的构建方式**:esbuild `platform: browser, format: cjs`,banner 注入 `window.__ModuleLoader__.load({ id: 'dsh-tu4-inline-images', factory: (require) => { var module = { exports: {} }, exports = module.exports;`,footer 注入 `return module.exports; }})`——与 tsdown.client.ts:562 同形(实现时对照 tsdown.client.ts 原文核对)。 - **React 获取方式**:slot-catalog 示例在 render 函数内直接用 `React.createElement`(无 import)→ 实现时先做 spike 验证 factory 作用域内 React 的提供方式(模块表 require 或 loader 注入),10 分钟前置项。 ### 3.4 白名单追加根 = 标准 settings 配置 - **机制**:`ctx.settings.register(namespace, zodSchema, { base? })`(settings 服务;活体模板 = modlens host 半注册 `'modlens'` 命名空间)。schema 的 zod 源 = `@deepseek-ai/schemastery`(DSH 核心同款,`packages/bundle/web-app/src/index.ts:19`)。 - **降级预案**:若 profile 运行时解析不到 schemastery → 手工校验 `extraRoots: string[]`(绝对路径数组)。实现时先验证。 ### 3.5 客户端取 token = 同源只读 `GET /state` - 路由 `GET /plugins/dsh-tu4-inline-images/state` → `{ "token": "<64-hex>" }`(`no-store`,只返回 token,无其他数据)。 - **信任模型论证**:token 本就存在于 DOM(③ 渲染出的 `` 含 token);暴露给同源客户端 JS 不扩大攻击面;与 modlens paste 路由(浏览器直调 `/modlens/paste`)同信任模型。token 仍是唯一门禁(继承 v2:响应 `no-store`/`nosniff`,SVG 加 `CSP: sandbox`,错误零回显)。 ### 3.6 标准设置卡片(GUI 设置界面,尺寸设置入口) - **slot `settings.plugin.item`**(keyed,scope root,**key 完全开放**):`slot-catalog.ts:1396-1417`("One plugin's card inside the plugin configuration section";keyDomain = "open: any string the owner dispatches... none are taken yet";现有租户 BashCard/AgentLoopCard/WebSearchCard 各用自己的 key,互不冲突)。 - **活体模板**:`@liustack/modlens` 客户端 `dsh/client.js:950-951`——`ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', id: 'modlens', key: 'modlens', order: 30 }, Card))`,Card 为 React 组件(设置卡片 UI)。 - **我们**:客户端 half 以 key `dsh-tu4-inline-images` 注册卡片 → GUI 设置界面"插件配置"区出现本插件卡片,内容 = **尺寸档位选择(大/中/小)** + 白名单追加目录(Q3 若确认)。数据面 = §3.4 的 settings 命名空间;UI 面 = 本卡片。**标准插件配置面 = settings 命名空间(数据)+ settings.plugin.item(卡片 UI)**。 ### 3.7 `dsh.plugin.json` 非 DSH 清单 - 全 DSH 源码 `packages/` grep `dsh.plugin.json` = **零引用**。DSH 实际读取:package.json 的 `dsh.bundle.patch`(指向 cordis.patch.yml)+ `dsh.client`(客户端 half)+ `exports`(入口解析)。 - `dsh.plugin.json` 是社区/dsh-plugin.app 的惯例(社区版自带该文件)。**保留该文件**仅为生态兼容;权威字段一律在 package.json。 --- ## 4. 架构(修订后) ``` host 半(现有,改白名单) 客户端 half(新增,纯展示,零 fs) ┌──────────────────────────────────┐ ┌──────────────────────────────────────┐ │ 图片路由 /image │◄───────│ ① conversation.input.dock 标准 slot │ │ token + 多根守护 + 限流读取 │ img │ (输入框上方整行;owner 快照 │ │ llm/stream 改写(③,已实现) │ src │ InputState.draft 自动重渲) │ │ tapIndex 尺寸 CSS(640×420,已实现) │ │ ② 用户消息气泡 DOM 后处理(保守策略) │ │ settings 命名空间 extraRoots(新) │ │ ③ 的 由产品 MarkdownText 渲染 │ │ GET /state → {token}(新,同源只读) │───────►│ 缩略图 src = 路由 URL │ └──────────────────────────────────┘ │ onerror → 撤图/还原文字(路由 404/413) │ 唯一文件读取执行点 = 路由 handler └──────────────────────────────────────┘ 客户端只发请求:语法匹配 ≠ 放行,加载失败即撤图 ``` - **① 预览行**:输入框上方整行(标准 slot,§3.1);图片展示上限随 `size` 档位(§5.1),行高自适应;常量 CSS 经 `document.createElement('style')` 注入(DSH 客户端惯例);无图片路径 → 渲染 null(零占位);**纯展示:无删除按钮、不改草稿**(路径文字必须保留——文本模型靠路径工作,发送行为不变)。 - **② 用户气泡**:见 §3.2 保守策略;**直接显示图片,与 LLM 输出一致**(Q1 确认);展示上限同 `size` 档位(客户端常量 CSS)。 - **不动 modlens**:粘贴 → 路径的插入行为保持(modlens 职责);我们只负责"路径出现 → 缩略图"。 ## 5. 白名单修订设计(多根,标准,可移植) | 根 | 来源 | 说明 | |---|---|---| | 会话 workspace | fs 服务 `config.cwd` | LLM 在项目内引用的图片(原有) | | modlens 粘贴根 | `join(os.tmpdir(), 'modlens-dsh-paste')` | DSH 粘贴图**标准落盘点**(modlens `pasteRoot()` = `join(tmpdir(), 'modlens-dsh-paste')`,modlens `dsh/index.js`);modlens 未安装时该根解析失败 → **静默跳过**,天然解耦 | | 追加根(用户配置) | `ctx.settings.register('dsh-tu4-inline-images', { extraRoots: string[] })` | 标准 settings,持久化,热更新(settings 变更 → 重解析根 → 更新 handler/rewrite deps) | - **守护算法不变**(v2):净化 → `fs.resolve`(realpath)→ 任一根 `fs.contains(rootTarget, target)` 通过 → stat 常规文件;多根 = 任一根包含即放行。 - **废弃**:`TU4_INLINE_ALLOWED_ROOT` 环境变量(代码中移除解析逻辑);`start-dsh-web.ps1` 已删除。 - **验收语义**:标准安装 + 任意工作区 + 无环境变量 → ①②③ 全工作。 ### 5.1 展示尺寸设置(Q2 确认:大/中/小三档,默认档) - **设置面**:settings 命名空间 `dsh-tu4-inline-images` = `{ size: 'small' | 'medium' | 'large' (默认 'medium'), extraRoots: string[] (Q3 已确认) }`;UI 入口 = §3.6 设置卡片(GUI 内切换,不碰代码/环境变量)。 - **档位值**(代码常量,默认值,可调): | 档 | 展示上限(max-width × max-height) | 说明 | |---|---|---| | small 小 | 320 × 240 | 紧凑预览 | | medium 中(默认) | 640 × 420 | 与当前 tapIndex 值一致(③ 现有行为不变) | | large 大 | 960 × 600 | 尽量大,仍受会话框约束 | - **CSS 形态**:`max-width + max-height + width:auto; height:auto`——**保持宽高比,只设上限,不强制拉伸**(与原图尺寸解耦,即"不一定生成缩略图")。 - **三处统一应用同一设置**: - ③ host:`tapIndex` 常量 CSS;**settings 变更 → dispose + 重新注册 tapIndex**(新常量),即时生效 - ①② client:客户端注入的 `
` 内的路径(代码块保持原样) - 替换为 ``;防重入标记;React 重渲染产生新节点 → observer 自动再处理 - `onerror`(路由 404/413)→ 还原路径文字 - **最坏情况 = 缩略图不出现、路径文字仍在,不破坏任何显示**;不改消息数据、不改草稿 ### 3.3 客户端 half 标准声明 - **package.json 字段**(权威格式,活体模板 = 本机 `@liustack/modlens` 的 package.json): ```json "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "inject": ["slots"], "platform": "web", "immediately": true } }, "exports": { ".": "./lib/index.js", "./client": "./lib/client.js" } ``` - **强制校验**:`packages/client/modules/src/index.ts:453`——声明 `dsh.client` 但 exports 无 `./client` 时 throw;`:459` `immediately: true` = 启动期预取。 - **bundle 顶层契约**:经典脚本必须调 `window.__ModuleLoader__.load({ id, factory })`,factory 接收模块表 `require`、返回 entry 导出(`packages/client/modules` + `tsdown.client.ts:562` banner 形态)。活体模板 `@liustack/modlens/dsh/client.js`:手写 lazy-CJS(`var module = {exports:{}}` 起手,`return exports` 收尾),零构建依赖。 - **我们的构建方式**:esbuild `platform: browser, format: cjs`,banner 注入 `window.__ModuleLoader__.load({ id: 'dsh-tu4-inline-images', factory: (require) => { var module = { exports: {} }, exports = module.exports;`,footer 注入 `return module.exports; }})`——与 tsdown.client.ts:562 同形(实现时对照 tsdown.client.ts 原文核对)。 - **React 获取方式**:slot-catalog 示例在 render 函数内直接用 `React.createElement`(无 import)→ 实现时先做 spike 验证 factory 作用域内 React 的提供方式(模块表 require 或 loader 注入),10 分钟前置项。 ### 3.4 白名单追加根 = 标准 settings 配置 - **机制**:`ctx.settings.register(namespace, zodSchema, { base? })`(settings 服务;活体模板 = modlens host 半注册 `'modlens'` 命名空间)。schema 的 zod 源 = `@deepseek-ai/schemastery`(DSH 核心同款,`packages/bundle/web-app/src/index.ts:19`)。 - **降级预案**:若 profile 运行时解析不到 schemastery → 手工校验 `extraRoots: string[]`(绝对路径数组)。实现时先验证。 ### 3.5 客户端取 token = 同源只读 `GET /state` - 路由 `GET /plugins/dsh-tu4-inline-images/state` → `{ "token": "<64-hex>" }`(`no-store`,只返回 token,无其他数据)。 - **信任模型论证**:token 本就存在于 DOM(③ 渲染出的 `` 含 token);暴露给同源客户端 JS 不扩大攻击面;与 modlens paste 路由(浏览器直调 `/modlens/paste`)同信任模型。token 仍是唯一门禁(继承 v2:响应 `no-store`/`nosniff`,SVG 加 `CSP: sandbox`,错误零回显)。 ### 3.6 标准设置卡片(GUI 设置界面,尺寸设置入口) - **slot `settings.plugin.item`**(keyed,scope root,**key 完全开放**):`slot-catalog.ts:1396-1417`("One plugin's card inside the plugin configuration section";keyDomain = "open: any string the owner dispatches... none are taken yet";现有租户 BashCard/AgentLoopCard/WebSearchCard 各用自己的 key,互不冲突)。 - **活体模板**:`@liustack/modlens` 客户端 `dsh/client.js:950-951`——`ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', id: 'modlens', key: 'modlens', order: 30 }, Card))`,Card 为 React 组件(设置卡片 UI)。 - **我们**:客户端 half 以 key `dsh-tu4-inline-images` 注册卡片 → GUI 设置界面"插件配置"区出现本插件卡片,内容 = **尺寸档位选择(大/中/小)** + 白名单追加目录(Q3 若确认)。数据面 = §3.4 的 settings 命名空间;UI 面 = 本卡片。**标准插件配置面 = settings 命名空间(数据)+ settings.plugin.item(卡片 UI)**。 ### 3.7 `dsh.plugin.json` 非 DSH 清单 - 全 DSH 源码 `packages/` grep `dsh.plugin.json` = **零引用**。DSH 实际读取:package.json 的 `dsh.bundle.patch`(指向 cordis.patch.yml)+ `dsh.client`(客户端 half)+ `exports`(入口解析)。 - `dsh.plugin.json` 是社区/dsh-plugin.app 的惯例(社区版自带该文件)。**保留该文件**仅为生态兼容;权威字段一律在 package.json。 --- ## 4. 架构(修订后) ``` host 半(现有,改白名单) 客户端 half(新增,纯展示,零 fs) ┌──────────────────────────────────┐ ┌──────────────────────────────────────┐ │ 图片路由 /image │◄───────│ ① conversation.input.dock 标准 slot │ │ token + 多根守护 + 限流读取 │ img │ (输入框上方整行;owner 快照 │ │ llm/stream 改写(③,已实现) │ src │ InputState.draft 自动重渲) │ │ tapIndex 尺寸 CSS(640×420,已实现) │ │ ② 用户消息气泡 DOM 后处理(保守策略) │ │ settings 命名空间 extraRoots(新) │ │ ③ 的 由产品 MarkdownText 渲染 │ │ GET /state → {token}(新,同源只读) │───────►│ 缩略图 src = 路由 URL │ └──────────────────────────────────┘ │ onerror → 撤图/还原文字(路由 404/413) │ 唯一文件读取执行点 = 路由 handler └──────────────────────────────────────┘ 客户端只发请求:语法匹配 ≠ 放行,加载失败即撤图 ``` - **① 预览行**:输入框上方整行(标准 slot,§3.1);图片展示上限随 `size` 档位(§5.1),行高自适应;常量 CSS 经 `document.createElement('style')` 注入(DSH 客户端惯例);无图片路径 → 渲染 null(零占位);**纯展示:无删除按钮、不改草稿**(路径文字必须保留——文本模型靠路径工作,发送行为不变)。 - **② 用户气泡**:见 §3.2 保守策略;**直接显示图片,与 LLM 输出一致**(Q1 确认);展示上限同 `size` 档位(客户端常量 CSS)。 - **不动 modlens**:粘贴 → 路径的插入行为保持(modlens 职责);我们只负责"路径出现 → 缩略图"。 ## 5. 白名单修订设计(多根,标准,可移植) | 根 | 来源 | 说明 | |---|---|---| | 会话 workspace | fs 服务 `config.cwd` | LLM 在项目内引用的图片(原有) | | modlens 粘贴根 | `join(os.tmpdir(), 'modlens-dsh-paste')` | DSH 粘贴图**标准落盘点**(modlens `pasteRoot()` = `join(tmpdir(), 'modlens-dsh-paste')`,modlens `dsh/index.js`);modlens 未安装时该根解析失败 → **静默跳过**,天然解耦 | | 追加根(用户配置) | `ctx.settings.register('dsh-tu4-inline-images', { extraRoots: string[] })` | 标准 settings,持久化,热更新(settings 变更 → 重解析根 → 更新 handler/rewrite deps) | - **守护算法不变**(v2):净化 → `fs.resolve`(realpath)→ 任一根 `fs.contains(rootTarget, target)` 通过 → stat 常规文件;多根 = 任一根包含即放行。 - **废弃**:`TU4_INLINE_ALLOWED_ROOT` 环境变量(代码中移除解析逻辑);`start-dsh-web.ps1` 已删除。 - **验收语义**:标准安装 + 任意工作区 + 无环境变量 → ①②③ 全工作。 ### 5.1 展示尺寸设置(Q2 确认:大/中/小三档,默认档) - **设置面**:settings 命名空间 `dsh-tu4-inline-images` = `{ size: 'small' | 'medium' | 'large' (默认 'medium'), extraRoots: string[] (Q3 已确认) }`;UI 入口 = §3.6 设置卡片(GUI 内切换,不碰代码/环境变量)。 - **档位值**(代码常量,默认值,可调): | 档 | 展示上限(max-width × max-height) | 说明 | |---|---|---| | small 小 | 320 × 240 | 紧凑预览 | | medium 中(默认) | 640 × 420 | 与当前 tapIndex 值一致(③ 现有行为不变) | | large 大 | 960 × 600 | 尽量大,仍受会话框约束 | - **CSS 形态**:`max-width + max-height + width:auto; height:auto`——**保持宽高比,只设上限,不强制拉伸**(与原图尺寸解耦,即"不一定生成缩略图")。 - **三处统一应用同一设置**: - ③ host:`tapIndex` 常量 CSS;**settings 变更 → dispose + 重新注册 tapIndex**(新常量),即时生效 - ①② client:客户端注入的 `