DeepSeek Media Gallery
DSH(cordis)插件:把一个媒体目录里的图片/视频列成画廊 —— 预览、Range 播放、下载到桌面、删除。入口是侧边栏底部的按钮,打开一个半透明弹出窗口(不占中央聊天区)。

插件分两半:
| 半边 | 文件 | 作用 |
|---|---|---|
| 宿主(Node) | lib/index.js |
cordis 插件:name / inject / Config / apply,注册 <prefix>/* 路由 |
| 浏览器 | lib/client.js |
DSH 客户端插件:window.__ModuleLoader__.load({id, factory}),apply(ctx) 里经 ctx.slots 注册入口按钮 + 弹窗 |
浏览器半边是 React 原生渲染,不是 iframe。
client/ 目录下另有一套独立页面(index.html + client.js),由宿主路由 <prefix>/ 与 <prefix>/client.js 提供,用于直接在浏览器打开画廊调试。它与 lib/client.js 无关,别混淆。
侧边栏与弹窗
| 插槽 | kind | 注册项 | 说明 |
|---|---|---|---|
sidebar.footer.action |
list | { id: 'media-gallery-panel', order: 60 } |
侧边栏底部(设置旁边)的入口按钮,owner props 含 wide |
shell.overlay |
list | { id: 'media-gallery-panel', order: 60 } |
帧级浮层,弹窗本体渲染在这里 |
刻意不注册 main / sidebar.panellist —— 那两个会把画廊变成占据中央区的常驻面板("把聊天框都占了")。弹窗关闭时组件返回 null,不常驻 DOM。
关闭方式有三种:右上角 ✕、点背景遮罩、按 Esc(先关预览大图,再关窗口)。
设置(位置可二次设置)
弹窗右上角齿轮进入设置页,三项路径都能改,保存后立即生效,不需要重启:
| 键 | 含义 | 默认 |
|---|---|---|
generatedDir |
图片存放位置(画廊扫描这里) | $DSH_HOME/plugin-data/image-gen/generated |
dataRoot |
记录存放位置(tasks.json / mcp-records.json) |
$DSH_HOME/plugin-data/media-gallery |
downloadDir |
下载位置 | 自动探测的桌面 |
- 「选择…」按钮先试宿主原生目录对话框(
ctx.uiWorkspace.pickDirectory()),失败则自动退到内置目录浏览器,并在顶部说明退化的原因。两条路径都拿不到时提示直接填路径。- 内置浏览器:盘符切换行(点一下换到别的盘)+ 面包屑跳转 + 逐层进入子文件夹 + 「选用此目录」+ 新建文件夹 + 「转到」直接粘路径(支持
E:\和\\server\share这类 UNC)。 - 为什么需要盘符行:
browse能力只会列"某个盘里面",crumbs的祖先链到C:就断了 —— 不列盘符就永远出不了当前盘。盘符只有宿主能枚举,所以走GET /api/drives(Windows 上探测 A–Z,比调 wmic/PowerShell 便宜也安全)。 - 为什么必须内置一个:
pick()需要native能力,而@deepseek-ai/dsh-host-directory-picker-auto的判定是if (facts.platform !== "linux" || !facts.linuxChooser) return "browse"—— 即 native 只在 Linux(zenity/kdialog + DISPLAY)成立,Windows/macOS 上pick()必然报directoryPicker.pick needs the native capability; the composed picker serves "browse"。而browse能力(listDirectory/createDirectory)处处可用,内置浏览器就走它。 - 另外两个踩过的坑:① 服务由
@deepseek-ai/dsh-client-ui-workspace提供,可能晚于本插件注册,所以不能在apply()时快照,必须点击现取;② cordis 的 reflect 层规定「属性访问未 inject 的服务会抛cannot get property "..." without inject」,而ctx.get(name)是 read a service without the inject requirement —— 必须先走ctx.get(),否则异常被 catch 吞掉,永远取不到服务。 listDirectory只接受全限定路径;输入框里是相对值时浏览器从宿主主目录起步。
- 内置浏览器:盘符切换行(点一下换到别的盘)+ 面包屑跳转 + 逐层进入子文件夹 + 「选用此目录」+ 新建文件夹 + 「转到」直接粘路径(支持
- 输入框留空(灰色占位符显示默认值)表示回退到默认值。
- 每行下方显示该目录当前状态:是否存在、以及图片目录里有多少个媒体文件。
- 值的优先级:用户保存的设置 > profile
cordis.patch.yml里的 config > 环境变量 > 自动默认。前三者通过 dsh-settings 的base组合层实现(generatedDir/dataRoot仍可用DEEPSEEK_MEDIA_GENERATED_DIR/DEEPSEEK_MEDIA_DATA_DIR覆盖种子)。
因为设置是每次请求实时读取的,改完目录后下一次 /api/tasks 就是新目录,无需重启 host。
目录扫描兜底
画廊不只列"登记过"的文件:tasks.json / mcp-records.json 里没有、但确实躺在图片目录里的媒体文件也会列出来(上限 500 个,按修改时间倒序,adapterId: "scan")。所以把插件指向一个装满图片的普通文件夹也能直接用。
背景板(与「律动」同款半透明)
dsh-rail-equalizer 的面板底色是 var(--dsw-specific-input-major, var(--dsw-alias-bg-base))。这个 token 由毛玻璃主题 wallpaper-engine 定义成 rgba(255,255,255,var(--we-glass-alpha)),所以只要用同一个 token,半透明观感就和律动一致,不需要自己拍透明度:
| 用途 | 取值 |
|---|---|
| 弹窗底色 | var(--dsw-specific-input-major, var(--dsw-alias-bg-base)) |
| 卡片底色 | var(--dsw-specific-bubble, var(--dsw-alias-bg-layer-1)) |
| 模糊 | blur(var(--we-blur,16px)) saturate(var(--we-saturate,1.8)) |
后两个 --we-* 变量同样来自 wallpaper-engine,缺省值即其默认值;没有主题时全部回落到官方 --dsw-alias-*。
下载(原素材作为备份,不动)
| 方式 | 行为 |
|---|---|
| 卡片上的 ⤓ / 预览里的「下载」 | POST /api/download → 服务端把文件复制到下载位置,原文件保持原样 |
| 复制失败(如没有写权限) | 自动退回浏览器下载 <prefix>/api/file/<name>(Content-Disposition: attachment) |
同名文件不会覆盖:第二份会存成 名字 (2).ext。删除才是真正删掉存档文件,别拿删除当"清空桌面"。
目录结构
lib/index.js 宿主半边(cordis 插件)
lib/client.js 浏览器半边(侧边栏按钮 + 弹窗 + 设置页)
client/index.html 独立页面
client/client.js 独立页面脚本(≠ lib/client.js)
cordis.patch.yml bundle patch:把本插件插进 profile 层栈
test/plugin.test.mjs 宿主半边测试(60 条)
test/client.test.mjs 浏览器半边测试(73 条)
test/preview.mjs 本地预览:用真实 handler 起临时服务
legacy/ Hana 形状的旧实现(routes/、旧 index.js、manifest.json),DSH 不加载,仅作参考
数据文件格式(dataRoot 下):
tasks.json:[{ taskId, status: "done", files: [...], prompt, modelId, createdAt }],只有status === "done"且文件存在才渲染。mcp-records.json:[{ taskId?, filename, prompt?, modelId?, createdAt? }],MCP 生图(ofapp-image-mcp)的产物登记在这里。
两处都会读,并按 filename 去重(同名时保留 tasks.json 的条目,元数据更全),剩下的再交给目录扫描兜底。
宿主路由(前缀默认 /deepseek-media-gallery)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | / |
独立页面 |
| GET | /client.js |
独立页面脚本 |
| GET | /api/tasks |
媒体列表(tasks + mcp-records + 目录扫描,去重) |
| GET | /api/media/:filename |
媒体流,支持 Range(bytes=0-9、bytes=-10、bytes=50-,越界/畸形 416) |
| GET | /api/file/:filename |
附件下载(浏览器兜底) |
| GET | /api/drives |
目录浏览器可跳转的根(Windows 盘符列表 / POSIX /) |
| GET | /api/settings |
当前值 / 默认值 / 各目录状态 |
| POST | /api/settings |
更新路径(空字符串 = 回退默认) |
| POST | /api/download |
复制到下载位置({ filename, destDir?, overwrite? }) |
| DELETE | /api/tasks/:id |
删除记录 + 存档文件 + 对应 mcp 记录;体 { "filename": "..." } |
删除不可撤销。filename 只接受媒体目录内的裸文件名,拒绝路径分隔符、..、: 与 NUL。
配置项
Config 是 schemastery schema(不能是普通对象:cordis 走 Config["~standard"].validate(),dsh-settings 会把 schema 当函数调用):
| 键 | 默认 | 说明 |
|---|---|---|
routePrefix |
/deepseek-media-gallery |
路由前缀 |
dshHome |
"" |
留空用 DSH_HOME |
generatedDir / dataRoot / downloadDir |
"" |
设置项的种子值,用户在面板里保存的值优先 |
浏览器半边用 PREFIX 常量拼请求地址(lib/client.js 顶部),改了 routePrefix 要同步改它。
开发
npm test # 宿主 60 条 + 浏览器 73 条
node test/preview.mjs --port 43199 \
--generated "<图片目录>" --data "<记录目录>"
test/ 下的脚本会先把 lib/、client/ 和自己复制到 ~/.dsh/profiles/ 下一个临时目录再跑 —— 插件自身没有 node_modules,@deepseek-ai/* 只有从 profile 的 node_modules 树里才解析得到。
说明与局限
- 纯本地:所有读写都在本机文件系统上完成,不联网、不上传任何东西。
- 宿主路由挂在 DSH 的本地 webserver 上(默认只监听
127.0.0.1),没有额外的鉴权层;routePrefix可以改,但别把它暴露到公网。 - 平台:Windows / macOS / Linux 都能用。差异只有目录选择器 —— 原生对话框仅 Linux 有(zenity/kdialog),其余平台自动走内置浏览器。
- 删除不可撤销:会同时删掉记录和磁盘上的媒体文件。下载是复制,不动原文件。
- 画廊按
dataRoot里的tasks.json/mcp-records.json索引;没有任何登记时用目录扫描兜底。这两个文件由你使用的生图插件(例如ofapp-image-mcp)写入。
安装
npm 包形状:package.json 的 dsh.bundle.patch 指向 cordis.patch.yml(插进 profile 层栈),dsh.client 声明浏览器半边(exports["./client"] → lib/client.js)。peer 依赖从 profile 的 node_modules 解析。改完需要重启 host 才会重新加载。
No comments yet. Be the first to write one.