@mokuyoaxis/dsh-iris 0.1.0 → 0.1.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/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@
6
6
 
7
7
  当前没有尚未发布的用户可见变更。
8
8
 
9
+ ## [0.1.1] - 2026-09-05
10
+
11
+ ### Changed
12
+
13
+ - 明确 ai-paint 是维护者未公开的本地前身而非用户依赖;插件默认启动不再读取其固定路径,仅在用户显式设置 `IRIS_IMPORT_WORKBENCH_CONFIG` 时由宿主本地一次性导入,已有供应商时跳过。
14
+ - `npm test` 使用 Node.js 调度器,移除 POSIX Shell 循环,保留逐文件进程隔离和失败即停。
15
+ - 对齐公开路线图与 `v0.1.0` 标签,区分当前 DSH 插件能力和未来宿主无关 Core/CLI/工作台。
16
+ - 工作台新增安全的媒体协议自动判断与显式选择;模型实测改为按能力触发,真实调用前确认,视频与转写不再提交空样本探针。
17
+ - 测试临时目录改用系统 API 并自动清理;`ffmpeg` 探测不再依赖 POSIX `which`。
18
+ - 增加 Linux/Windows × Node.js 20.10/22 CI 矩阵和发布前完整测试钩子。
19
+ - 新增标签驱动的 Release 工作流:推送 `vX.Y.Z` 标签即自动跑全量测试、校验标签与 package.json 版本一致、创建附 tarball 的 GitHub Release 并发布 npm(含 provenance;未配置 `NPM_TOKEN` 时仅跳过 npm 发布)。
20
+ - Node.js 最低版本调整为 20.10;20.9 无法解析当前图片依赖使用的 JSON import attributes。
21
+
22
+ ### Fixed
23
+
24
+ - 修复 DSH 0.1.2+ Web 客户端按完整 npm 包名校验时,Iris 仍以旧短名注册而导致整个插件组合包加载失败的问题;同时将客户端依赖声明对齐新版的 Renderer/Session 服务。
25
+ - 旧配置为 `null` 或供应商凭据字段类型错误时,跳过无效内容而不使导入崩溃。
26
+ - 语法合法但根节点、供应商、模型、任务或分配结构错误的持久化文件现在会被隔离并安全重建,不再静默丢失后续更新。
27
+ - 大媒体下载改为流式原子落盘,避免把完整视频一次性载入内存,并清理失败的临时文件。
28
+ - Windows CI:Skill 测试先归一化 CRLF 再解析 frontmatter,并新增 `.gitattributes` 强制所有平台以 LF 检出(Windows runner 默认 `core.autocrlf=true` 曾致 `npm test` 失败)。
29
+ - Windows CI:`tests/pixels.mjs` 改用 `fileURLToPath` 解析模块路径;`URL.pathname` 在 Windows 上返回 `/D:/...`,拼出的非法临时目录路径使该测试进程直接崩溃。
30
+ - 上传接口竞态:写入流改为惰性创建、`.part` 清理等待 fd 关闭,空上传或提前失败的请求不再可能残留孤儿 `.part` 文件。
31
+
32
+ ### Security
33
+
34
+ - DashScope 凭据在网络请求发出前绑定到阿里云官方 HTTPS 域名,阻止错误协议配置把 API Key 发给第三方端点。
35
+ - POSIX 上统一以 `0700` 目录和 `0600` 文件保存 Iris 配置、任务、上传及产物;启动时收紧既有 Iris 树且不跟随符号链接。
36
+ - `/iris/*` 默认只接受回环 Host;LAN/反向代理需显式配置 `IRIS_TRUSTED_HOSTS`,公开文档明确该列表不替代身份认证。
37
+
9
38
  ## [0.1.0] - 2026-09-04
10
39
 
11
40
  ### Added
@@ -49,5 +78,6 @@
49
78
  - 上传采用流式限额、临时 `.part` 文件和原子落盘,失败时会清理未完成文件。
50
79
  - 修改状态的路由拒绝明确的跨站请求,媒体文件继续使用随机能力令牌访问。
51
80
 
52
- [Unreleased]: https://github.com/mokuyoaxis/dsh-iris/compare/v0.1.0...HEAD
81
+ [Unreleased]: https://github.com/mokuyoaxis/dsh-iris/compare/v0.1.1...HEAD
82
+ [0.1.1]: https://github.com/mokuyoaxis/dsh-iris/compare/v0.1.0...v0.1.1
53
83
  [0.1.0]: https://github.com/mokuyoaxis/dsh-iris/releases/tag/v0.1.0
package/README.md CHANGED
@@ -4,13 +4,13 @@
4
4
 
5
5
  <h1 align="center">dsh-iris</h1>
6
6
 
7
- <p align="center"><strong>DeepSeek Harness 的多供应商媒体与视觉工具箱</strong></p>
7
+ <p align="center"><strong>Iris 多模态生产运行时 · 当前通过 DeepSeek Harness 插件使用</strong></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="#deepseek-harness-适配"><img alt="DeepSeek Harness compatible" src="https://img.shields.io/badge/DeepSeek%20Harness-compatible-4D6BFE.svg?style=flat-square"></a>
11
- <a href="CHANGELOG.md"><img alt="Version 0.1.0" src="https://img.shields.io/badge/version-0.1.0-7C5CFC.svg?style=flat-square"></a>
12
- <a href="https://nodejs.org/"><img alt="Node.js 20.9 or newer" src="https://img.shields.io/badge/Node.js-%3E%3D20.9-339933.svg?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white"></a>
13
- <a href="#开发与验证"><img alt="npm test" src="https://img.shields.io/badge/tests-npm%20test-2EA44F.svg?style=flat-square"></a>
11
+ <a href="https://www.npmjs.com/package/@mokuyoaxis/dsh-iris"><img alt="npm version" src="https://img.shields.io/npm/v/%40mokuyoaxis%2Fdsh-iris.svg?style=flat-square"></a>
12
+ <a href="https://nodejs.org/"><img alt="Node.js 20.10 or newer" src="https://img.shields.io/badge/Node.js-%3E%3D20.10-339933.svg?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white"></a>
13
+ <a href="https://github.com/mokuyoaxis/dsh-iris/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mokuyoaxis/dsh-iris/actions/workflows/ci.yml/badge.svg"></a>
14
14
  <a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-1689FF.svg?style=flat-square"></a>
15
15
  </p>
16
16
 
@@ -18,9 +18,17 @@ dsh-iris 为 Agent 和 Iris 工作台提供图像、视频、语音与视觉理
18
18
 
19
19
  项目目前处于早期版本,接口和配置格式仍可能随版本迭代调整。
20
20
 
21
+ Iris 的长期方向是可独立运行、可接入不同 Agent 宿主的媒体生产核心。DeepSeek Harness 是首个受支持宿主;当前版本仍需要 DSH,独立 Core、CLI 和工作台属于后续路线,尚未提供。
22
+
23
+ ## 与 ai-paint 的关系
24
+
25
+ `ai-paint` 是 Iris 维护者本机未公开的前身项目,不是 Iris 的公开依赖,也不是用户需要下载、安装或自行创建的目录。没有该项目的用户直接按下文在 Iris 工作台配置供应商即可。
26
+
27
+ 早期版本从维护者本机的 ai-paint 配置读取模型与凭据,既用于迁移前身项目,也让宿主进程可以在本地复用已有 API Key,避免为迁移而把密钥粘贴到 Agent 会话、命令行参数或文档中。0.1.1 将两者的配置彻底分离:Iris 只使用自己的配置;旧配置仅可通过把 `IRIS_IMPORT_WORKBENCH_CONFIG` 显式设为本地配置文件的绝对路径进行一次性导入,不会默认扫描 ai-paint,也不会在两边同步。该变量保存的是文件路径而不是 API Key,来源文件不会被修改;具体操作见[用户指南](user_guide.md#从旧工作台显式导入)。
28
+
21
29
  ## 最快开始
22
30
 
23
- 已经安装 DeepSeek Harness 且 `pnpm` 在 PATH 中时,把 Iris 加入 Web profile:
31
+ 已经安装 DeepSeek Harness `0.1.2-rc.1` 且 `pnpm` 在 PATH 中时,把 Iris 加入 Web profile:
24
32
 
25
33
  ```bash
26
34
  dsh plugin --profile web add @mokuyoaxis/dsh-iris
@@ -53,18 +61,20 @@ DSH 的 profile 与插件命令由[官方安装说明](https://github.com/deepse
53
61
 
54
62
  ## 使用前准备
55
63
 
56
- - 满足所用 DeepSeek Harness 版本要求的 Node.js;dsh-iris 自身最低为 20.9
64
+ - 满足所用 DeepSeek Harness 版本要求的 Node.js;dsh-iris 自身最低为 20.10
57
65
  - 一个可用的 DeepSeek Harness 环境,以及 PATH 中的 `pnpm`
58
66
  - 至少一个受支持的媒体或视觉服务供应商
59
67
  - 使用视频抽帧和视频摘要时,需要系统提供 `ffmpeg` 与 `ffprobe`
60
68
 
61
- 安装插件后,在 Iris 工作台中添加供应商并为所需能力分配模型。供应商密钥只保存在宿主侧,界面和接口不会返回完整密钥。
69
+ 安装插件后,在 Iris 工作台中添加供应商并为所需能力分配模型。供应商密钥保存在宿主侧的 Iris 配置中;POSIX 系统上 Iris 自有目录和文件分别收紧为 `0700` 与 `0600`,界面和接口不会返回完整密钥。Windows 的 mode 不等同于 ACL,仍需依赖当前用户目录和系统账户权限。
62
70
 
63
71
  ## DeepSeek Harness 适配
64
72
 
65
73
  dsh-iris 按 DeepSeek Harness 插件形态提供服务端与 Web 客户端入口:服务端注册 14 个 Agent 工具,并复用宿主的工具、路由和生命周期服务;客户端通过 DSH 模块加载器接入设置页、会话输入区和全局悬浮层。插件不会启动独立服务或额外监听端口。
66
74
 
67
- 当前自动化测试覆盖插件装载、工具注册、客户端槽位和路由行为。由于项目尚未声明 DSH 的最低或最高版本,兼容徽章表示项目已按当前 DSH 插件接口实现,并不代表官方认证或对所有历史版本的承诺。
75
+ 当前自动化测试覆盖插件装载、工具注册、客户端槽位和路由行为。0.1.1 已在 Linux ARM64 的干净与真实 Web profile 中,使用 DSH `0.1.2-rc.1`、Node.js `22.23.2` 完成宿主烟测;浏览器启动图、完整组合 bundle、Iris 客户端工厂和三个 UI 座位均已验证。Iris 自身仍以 Node.js `>=20.10` 为最低基线。
76
+
77
+ 0.1.1 明确支持 DSH `>=0.1.2-rc.1 <0.1.3-0`,不再兼容 0.1.0/0.1.1 的旧客户端 Runtime。DSH 仍在快速演进,后续预览版须经验证后再扩大范围;兼容徽章不代表官方认证。若 DSH 要求更高 Node 版本,以 DSH 为准。
68
78
 
69
79
  ## 工具
70
80
 
@@ -126,6 +136,8 @@ Iris 支持三种文件来源,推荐顺序如下:
126
136
  | `outputs/` | 生成和处理后的媒体文件 |
127
137
  | `uploads/` | 浏览器上传的临时输入副本 |
128
138
 
139
+ 0.1.1 首次装载时会收紧既有 `$DSH_HOME/iris/v1/` 树的 POSIX 权限,不修改文件内容,也不跟随符号链接。大媒体下载采用流式私有临时文件与原子替换,不再把整段视频载入内存。
140
+
129
141
  异步任务会在后台轮询。插件重启后,可以继续接管仍在远端执行的任务。取消信号会传递到本地等待与轮询流程;是否能取消远端计算,取决于供应商接口。
130
142
 
131
143
  音频和视频通过带随机令牌的 Iris 媒体链接访问。图片会尽量转存为 DSH 持久附件,方便在会话中继续使用。
@@ -133,7 +145,10 @@ Iris 支持三种文件来源,推荐顺序如下:
133
145
  ## 安全说明
134
146
 
135
147
  - 供应商密钥不会通过状态接口返回。
136
- - 修改状态的 HTTP 请求会检查跨站来源,降低第三方页面触发付费操作的风险。
148
+ - DashScope 媒体协议只允许把密钥发送到阿里云官方 HTTPS 域名;其他 Base URL 默认推断为 OpenAI Images 兼容协议。
149
+ - `/iris/*` 默认只接受回环 Host;LAN 或反向代理必须显式设置 `IRIS_TRUSTED_HOSTS`,修改状态的请求还会检查跨站来源。
150
+ - `IRIS_TRUSTED_HOSTS` 不是认证机制。对公网或不可信网络开放 DSH 时,必须在反向代理或宿主层配置身份认证与 HTTPS。
151
+ - 模型实测只按单项能力运行,并在真实供应商调用前确认;视频和转写不会用空样本自动提交付费探针。
137
152
  - HTML 截图在不具备同源权限的沙箱页面中渲染,脚本和外部网络默认不可用。
138
153
  - 媒体链接使用随机能力令牌,文件路径只从任务记录解析。
139
154
  - Iris 不提供独立账号体系,多用户隔离和访问控制由 DeepSeek Harness 部署负责。
@@ -165,6 +180,10 @@ Iris 支持三种文件来源,推荐顺序如下:
165
180
 
166
181
  测试覆盖配置合并、模型身份、任务生命周期、生成动作、HTTP/SSE 路由、客户端交互和安全边界。项目采用 ES Modules,不需要构建步骤。
167
182
 
183
+ 测试调度使用 Node.js,逐文件启动独立进程,首个失败即停止,不依赖 Bash;临时目录使用系统 API 并在退出时清理。GitHub Actions 定义 Linux/Windows × Node.js 20.10/22 矩阵,发布前钩子会重新运行完整测试。原生 Windows/WSL 的 DSH 宿主烟测仍待实机验证。
184
+
185
+ 发布由 `v*` 标签触发 `.github/workflows/release.yml`:跑完整测试后打包,用工作流内置 `GITHUB_TOKEN` 创建 GitHub Release(附 tarball,发布说明取自 CHANGELOG 对应章节),并在配置了 `NPM_TOKEN` secret 时发布到 npm。本机无需任何 GitHub 凭据。
186
+
168
187
  ## 文档
169
188
 
170
189
  - [用户指南](user_guide.md)
package/docs/ROADMAP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 路线图
2
2
 
3
- dsh-iris 已具备完整的媒体生成、视觉处理和任务管理主流程。接下来的重点是跨平台验证、发布体验和少量高价值增强,而不是继续扩张工具数量。
3
+ dsh-iris 已具备媒体生成、视觉处理和任务管理主流程。长期方向是宿主无关的多模态生产运行时,DSH 是首个受支持宿主。当前仍通过 DSH 插件使用,独立 Core、CLI 和工作台尚未实现。
4
4
 
5
5
  变更详情见[变更记录](../CHANGELOG.md),完整工具清单见[README](../README.md)。
6
6
 
@@ -17,40 +17,50 @@ dsh-iris 已具备完整的媒体生成、视觉处理和任务管理主流程
17
17
  - 面向 Agent 与用户的统一视频生成实现
18
18
  - 两个仓库级 Agent Skills:UI 视觉验收与多步骤媒体编排
19
19
 
20
- 当前包版本为 `0.1.0`,尚未建立正式发布标签。
20
+ 当前版本为 `v0.1.1`。该补丁完成配置独立、安全与工程收口,并把 Web loader 适配固定到已验证的 DSH `0.1.2-rc.1` 接口;更高预览版在进入支持范围前需要重新运行宿主 canary。
21
21
 
22
- ## 下一步
22
+ ## 已完成:0.1.1
23
+
24
+ ### 配置独立与工程收口
23
25
 
24
- ### 0.2.0 发布准备
26
+ - 对齐公开文档和已发布标签,明确当前能力与未来方向。
27
+ - Iris 独立保存配置;ai-paint 仅作为维护者未公开的本地前身和可选迁移来源,不是用户依赖。停止默认读取,仅保留显式指定本地来源的一次性安全导入。
28
+ - 用 Node.js 调度测试,去除对 POSIX Shell 循环的依赖。
29
+ - 绑定供应商凭据与协议端点,统一私有存储权限和流式原子落盘,并验证损坏配置恢复。
30
+ - 将付费能力实测改为单项显式确认,视频与转写不做空样本自动探针。
31
+ - 默认将 Iris HTTP 路由限制在回环 Host,为反代提供显式信任列表和认证部署说明。
32
+ - 增加 Linux/Windows 与 Node.js 20.9/22 CI 矩阵、发布前测试护栏,并以当前 DSH 预览版完成宿主烟测。
33
+ - 核对打包清单,记录真实环境和未验证平台。
34
+
35
+ ## 下一步
25
36
 
26
- - 在原生 Windows、WSL 和常规 Linux 环境完成安装与文件输入冒烟测试。
27
- - 在全新 DeepSeek Harness 环境验证插件装载、客户端入口、工具注册和路由注册。
28
- - 检查 npm 包内容、公开文档链接和最低 Node.js 版本。
29
- - 整理版本号、发布日期和升级说明后再创建正式发布。
37
+ ### 后续 0.1.x:可信基座
30
38
 
31
- ### 使用体验
39
+ - 区分提交未受理、已受理与受理未知,完善 Attempt、取消和恢复语义。
40
+ - 用故障注入验证提交响应丢失、下载和落盘失败时不会重复提交。
41
+ - 定义最小宿主与供应商契约,提供离线诊断入口。
32
42
 
33
- - 根据真实使用反馈改进文件选择器、任务历史和错误提示。
34
- - 评估按媒体类型调整上传上限,兼顾大视频与远程部署。
35
- - 在真实 DSH 会话验证 `iris-compose-media` 的触发与停止边界,再决定两个仓库级 Skills 的自动注册方式。
43
+ ### 0.2.0:独立 Core 与产物记录
36
44
 
37
- ### 能力增强
45
+ - 统一 Agent、工作台与 CLI 的命令执行核心,将 DSH 集成集中到宿主适配器。
46
+ - 为产物保存内容哈希、来源和派生关系,支持检查与导出。
47
+ - 在未安装 DSH 的干净环境完成本地动作和供应商任务验收。
38
48
 
39
- 以下方向会按需求和供应商支持情况安排:
49
+ ### 后续方向
40
50
 
41
- - 流式语音合成
42
- - 批量任务和可控并发
43
- - 更丰富的图片比较指标,例如 SSIM、色彩空间和 EXIF 检查
44
- - 最近使用文件和更易发现的历史产物入口
51
+ - 0.3:有界、可恢复的媒体工作流,以及用户显式启动的本地 API 和最小独立工作台。
52
+ - 0.4:根据真实消费者需要开放供应商和宿主适配契约。
53
+ - 1.0:冻结经过验证的公共契约和升级策略。版本表示目标聚合点,不代表发布日期承诺。
45
54
 
46
55
  ## 兼容性与限制
47
56
 
48
- - Node.js 最低版本为 20.9。
57
+ - Node.js 最低版本为 20.10。
49
58
  - 图片处理依赖 `sharp`,非标准 libc 或移动终端环境可能没有可用的预编译包。
50
59
  - 视频抽帧与视频摘要依赖 `ffmpeg` 和 `ffprobe`。
51
60
  - WSL、远程浏览器和容器部署应优先使用上传或会话附件,避免混淆浏览器路径与宿主路径。
52
61
  - HTML 截图运行在离线沙箱中,远程资源和页面脚本不可用。
53
62
  - 模型发现只能列出候选项,具体能力需要通过实际请求确认。
63
+ - Node 测试调度器不依赖 Bash;原生 Windows/WSL 完整测试与 DSH 冒烟尚待验证,不因调度器改写而宣称已支持全部平台。
54
64
 
55
65
  ## 质量要求
56
66
 
@@ -67,6 +77,7 @@ dsh-iris 已具备完整的媒体生成、视觉处理和任务管理主流程
67
77
 
68
78
  - 在插件内实现账号、权限或多租户系统。
69
79
  - 接管 DeepSeek Harness 的通用聊天模型配置。
70
- - 启动独立后台服务或额外监听端口。
80
+ - 由 DSH 插件隐式启动独立后台服务或额外监听端口。
81
+ - 在本地工作台阶段建设公网 SaaS、组织账号和多租户系统。
71
82
  - 自动探测全部模型能力并触发可能计费的请求。
72
83
  - 仅为未来可能使用的供应商预建没有调用方的适配层。
@@ -0,0 +1,51 @@
1
+ Iris v0.1.1 is a compatibility and hardening release of the media and visual
2
+ workspace for DeepSeek Harness, targeting DSH `>=0.1.2-rc.1 <0.1.3-0`.
3
+
4
+ ## Highlights
5
+ - Register the client under the full npm package name, fixing a load failure
6
+ on DSH 0.1.2+ Web clients.
7
+ - Raise the minimum Node.js to 20.10; the image dependency requires JSON
8
+ import attributes that 20.9 cannot parse.
9
+ - The workbench now auto-detects media protocols with an explicit override,
10
+ and probes models per capability with confirmation before any paid call.
11
+
12
+ ## Reliability and data safety
13
+ - Corrupted persistence files are quarantined with the original evidence
14
+ preserved and rebuilt safely, instead of crashing or silently losing updates.
15
+ - Large media downloads use streaming atomic writes; failed downloads clean
16
+ up their temporary files.
17
+ - Legacy ai-paint configuration is imported only when `IRIS_IMPORT_WORKBENCH_CONFIG`
18
+ is set explicitly; default startup no longer reads external paths.
19
+ - DashScope credentials are bound to official HTTPS endpoints before any
20
+ request is sent.
21
+ - On POSIX, Iris state is stored under `0700` directories and `0600` files;
22
+ `/iris/*` accepts loopback hosts only unless `IRIS_TRUSTED_HOSTS` is configured.
23
+
24
+ ## Testing and release pipeline
25
+ - The full regression suite runs on a Linux/Windows × Node.js 20.10/22 matrix,
26
+ with a pure Node.js scheduler, per-file isolation, and automatic temp cleanup.
27
+ - Windows support hardened end to end: LF-forced checkout, CRLF-tolerant
28
+ parsers, and cross-platform module paths.
29
+ - Releases are now tag-driven: pushing `vX.Y.Z` runs the full suite, verifies
30
+ the tag against `package.json`, creates this Release with the packaged
31
+ tarball, and publishes to npm with provenance.
32
+
33
+ ## Install
34
+ ```bash
35
+ dsh plugin --profile web add @mokuyoaxis/dsh-iris
36
+ dsh web
37
+ ```
38
+ > Requires Node.js 20.10.0 or later and DSH `>=0.1.2-rc.1`. Video frame
39
+ > extraction and video summaries require ffmpeg and ffprobe.
40
+
41
+ See the [README](https://github.com/mokuyoaxis/dsh-iris#readme) and
42
+ [User Guide](https://github.com/mokuyoaxis/dsh-iris/blob/v0.1.1/user_guide.md)
43
+ for provider and model configuration.
44
+
45
+ Image, video, and speech operations may incur charges from the configured provider.
46
+
47
+ ## Package
48
+ - npm: [@mokuyoaxis/dsh-iris](https://www.npmjs.com/package/@mokuyoaxis/dsh-iris)
49
+ - Changelog: [v0.1.1](https://github.com/mokuyoaxis/dsh-iris/blob/v0.1.1/CHANGELOG.md)
50
+
51
+ **Full Changelog**: https://github.com/mokuyoaxis/dsh-iris/compare/v0.1.0...v0.1.1
package/lib/actions.js CHANGED
@@ -24,6 +24,8 @@ import { BrowserHtmlRenderer } from './render.js';
24
24
  import { longOcr } from './ocr.js';
25
25
  import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
26
26
  import { buildContactSheet, summarizeMedia } from './summarize.js';
27
+ import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
28
+ import { inferMediaProtocol } from './provider-protocol.js';
27
29
 
28
30
  const MEDIA_TYPES = {
29
31
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
@@ -49,8 +51,7 @@ async function describeFirstImage(ctx, ref, absPath, originalPrompt) {
49
51
  /* ---------------- IO 目录 ---------------- */
50
52
  function outputDir() {
51
53
  const dir = path.join(store.irisHome(), 'outputs');
52
- fs.mkdirSync(dir, { recursive: true });
53
- return dir;
54
+ return ensurePrivateDir(dir);
54
55
  }
55
56
 
56
57
  function throwIfAborted(signal) {
@@ -160,7 +161,7 @@ register('image', async (ctx, args, { signal }) => {
160
161
  const prompt = String(args.prompt || '').trim();
161
162
  if (!prompt) throw new Error('iris: prompt 不能为空');
162
163
  const submitted = await submitWithFailover(cap.CAPABILITIES.IMAGE, '图像', signal, args.model, async (provider) => {
163
- const protocol = (provider.mediaProtocol || 'dashscope') === 'openai-images' ? 'openai-images' : 'dashscope';
164
+ const protocol = provider.mediaProtocol || inferMediaProtocol(provider.baseUrl);
164
165
  const model = args.model ? (models.parseModelRef(String(args.model))?.modelId || String(args.model)) : (provider.imageModel || (protocol === 'openai-images' ? 'gpt-image-1' : 'wan2.2-t2i-flash'));
165
166
  const task = tasks.create({
166
167
  cap: 'image', providerId: provider.id, providerName: provider.name,
@@ -168,7 +169,7 @@ register('image', async (ctx, args, { signal }) => {
168
169
  });
169
170
  if (protocol === 'dashscope') {
170
171
  const started = await tasks.submitGuard(task, () => adapters.startImageGeneration({
171
- key: provider.apiKey, model, prompt, size: args.size, n: args.n, signal
172
+ key: provider.apiKey, baseUrl: provider.baseUrl, model, prompt, size: args.size, n: args.n, signal
172
173
  }));
173
174
  const remoteTaskId = started.remoteTaskId;
174
175
  if (!remoteTaskId) {
@@ -190,7 +191,7 @@ register('image', async (ctx, args, { signal }) => {
190
191
  tasks.update(task.id, { remoteTaskId });
191
192
  tasks.watch(tasks.get(task.id), {
192
193
  key: () => provider.apiKey,
193
- poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, remoteTaskId }),
194
+ poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId }),
194
195
  intervalMs: 2500,
195
196
  onSuccess: async (t, r) => {
196
197
  const names = [];
@@ -212,7 +213,7 @@ register('image', async (ctx, args, { signal }) => {
212
213
  const written = [];
213
214
  for (let i = 0; i < outs.length; i++) {
214
215
  const p = path.join(outputDir(), `${task.id}-${i}.png`);
215
- if (outs[i].b64) fs.writeFileSync(p, Buffer.from(outs[i].b64, 'base64'));
216
+ if (outs[i].b64) writePrivateFile(p, Buffer.from(outs[i].b64, 'base64'));
216
217
  else await adapters.downloadTo(outs[i].url, p, { signal });
217
218
  written.push(p);
218
219
  }
@@ -255,8 +256,8 @@ register('video', async (ctx, args, { signal }) => {
255
256
  const audioPath = String(args.audio_path || '').trim();
256
257
  if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: s2v 的 audio_path 必须是绝对路径');
257
258
  if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在:' + audioPath);
258
- const imageUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: frame.absPath, signal });
259
- const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: audioPath, signal });
259
+ const imageUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: frame.absPath, signal });
260
+ const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: audioPath, signal });
260
261
  submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
261
262
  } else {
262
263
  submitArgs = {
@@ -266,12 +267,12 @@ register('video', async (ctx, args, { signal }) => {
266
267
  duration: args.duration ? Number(args.duration) : undefined
267
268
  };
268
269
  }
269
- return adapters.submitVideo({ key: provider.apiKey, model, ...submitArgs, signal });
270
+ return adapters.submitVideo({ key: provider.apiKey, baseUrl: provider.baseUrl, model, ...submitArgs, signal });
270
271
  });
271
272
  tasks.update(task.id, { remoteTaskId });
272
273
  tasks.watch(tasks.get(task.id), {
273
274
  key: () => provider.apiKey,
274
- poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, remoteTaskId: id }),
275
+ poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId: id }),
275
276
  intervalMs: 6000,
276
277
  onSuccess: async (t, r) => {
277
278
  const names = [];
@@ -302,11 +303,11 @@ register('tts', async (ctx, args, { signal }) => {
302
303
  const voice = args.voice || (provider.ttsVoice || 'Cherry');
303
304
  const task = tasks.create({ cap: 'tts', providerId: provider.id, providerName: provider.name, model, voice, prompt: text.slice(0, 500) });
304
305
  const fileName = await tasks.submitGuard(task, async () => {
305
- const r = await adapters.synthesizeTts({ key: provider.apiKey, model, text, voice, signal });
306
+ const r = await adapters.synthesizeTts({ key: provider.apiKey, baseUrl: provider.baseUrl, model, text, voice, signal });
306
307
  const name = `iris-tts-${Date.now()}.${r.audioUrl ? adapters.extFromUrl(r.audioUrl, 'wav') : 'wav'}`;
307
308
  const p = path.join(outputDir(), name);
308
309
  if (r.audioUrl) await adapters.downloadTo(r.audioUrl, p, { signal });
309
- else fs.writeFileSync(p, Buffer.from(r.audioB64, 'base64'));
310
+ else writePrivateFile(p, Buffer.from(r.audioB64, 'base64'));
310
311
  return name;
311
312
  });
312
313
  tasks.update(task.id, { status: 'succeeded', files: [fileName], finishedAt: new Date().toISOString() });
@@ -530,7 +531,7 @@ register('providers_list', async (ctx, args) => {
530
531
  type: p.type || 'openai',
531
532
  baseUrl: p.baseUrl,
532
533
  enabled: p.enabled !== false,
533
- mediaProtocol: p.mediaProtocol || 'dashscope',
534
+ mediaProtocol: p.mediaProtocol || inferMediaProtocol(p.baseUrl),
534
535
  apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
535
536
  models: models.providerModels(p).map((m) => ({ id: m.id, capabilities: m.capabilities, verified: m.verified || null, source: m.source || 'auto' })),
536
537
  capabilities: store.capabilitiesOf(p)
@@ -599,6 +600,7 @@ register('providers_test_vision', async (ctx, args) => {
599
600
  const backends = buildVisionBackends(ctx, { providers: visionProviders });
600
601
  const backend = backends[0];
601
602
  if (!backend) return { ok: true, tested: false, text: '该供应商的 vision 配置不可用(类型非 openai)' };
603
+ if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
602
604
  const r = await testVisionCapability(backend, { timeoutMs: 15000, signal: args._signal });
603
605
  return {
604
606
  ok: true,
@@ -612,7 +614,7 @@ register('providers_test_vision', async (ctx, args) => {
612
614
 
613
615
  /* ============ 模型池逐模型管理(阶段 9 P3:手动增删改 + 逐能力实测) ============ */
614
616
 
615
- /** 按能力实测单个模型;返回 {ok, note}。image/video 会产生真实调用(成本已在 UI 标注)。 */
617
+ /** 按能力实测单个模型;返回 {ok, note}。真实调用仅在 UI 明确确认后执行。 */
616
618
  async function probeModel(ctx, p, modelId, capability) {
617
619
  if (capability === cap.CAPABILITIES.VISION) {
618
620
  const backend = buildVisionBackends(ctx, { providers: [p], model: modelId })[0];
@@ -621,34 +623,32 @@ async function probeModel(ctx, p, modelId, capability) {
621
623
  return { ok: r.ok, note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色') };
622
624
  }
623
625
  if (capability === cap.CAPABILITIES.TRANSCRIBE) {
624
- throw new Error('iris: 转写能力请用真实音频通过“音频转写”动作验证,不执行空样本探针');
626
+ return { skipped: true, ok: false, note: '需真实音频,请通过“音频转写”动作验证' };
625
627
  }
626
628
  if (capability === cap.CAPABILITIES.TTS) {
627
- const r = await adapters.synthesizeTts({ key: p.apiKey, model: modelId, text: '你好', voice: 'Cherry' });
629
+ const r = await adapters.synthesizeTts({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, text: '你好', voice: 'Cherry' });
628
630
  const ok = !!(r.audioUrl || r.audioB64);
629
631
  return { ok, note: ok ? '合成返回音频' : '无音频返回' };
630
632
  }
631
633
  if (capability === cap.CAPABILITIES.IMAGE) {
632
- if ((p.mediaProtocol || 'dashscope') === 'openai-images') {
634
+ if ((p.mediaProtocol || inferMediaProtocol(p.baseUrl)) === 'openai-images') {
633
635
  const outs = await adapters.openAiGenerateImage({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, prompt: 'red circle', size: '256x256', n: 1 });
634
636
  return { ok: outs.length > 0, note: outs.length ? '生成返回图片' : '无图片返回' };
635
637
  }
636
- const started = await adapters.startImageGeneration({ key: p.apiKey, model: modelId, prompt: '红色圆形', size: '512*512', n: 1 });
638
+ const started = await adapters.startImageGeneration({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, prompt: '红色圆形', size: '512*512', n: 1 });
637
639
  if (!started.remoteTaskId) {
638
640
  return { ok: (started.urls || []).length > 0, note: (started.urls || []).length ? '生成成功' : '无图片返回' };
639
641
  }
640
642
  // 有界轮询到终态(图像通常 5–15s)
641
643
  for (let i = 0; i < 20; i++) {
642
644
  await new Promise((r) => setTimeout(r, 3000));
643
- const st = await adapters.pollTask({ key: p.apiKey, remoteTaskId: started.remoteTaskId });
645
+ const st = await adapters.pollTask({ key: p.apiKey, baseUrl: p.baseUrl, remoteTaskId: started.remoteTaskId });
644
646
  if (st.done) return { ok: st.ok && (st.urls || []).length > 0, note: st.ok ? '生成成功' : (st.message || '生成失败') };
645
647
  }
646
648
  return { ok: false, note: '生成超时(>60s)' };
647
649
  }
648
650
  if (capability === cap.CAPABILITIES.VIDEO) {
649
- // 视频渲染太慢太贵:只做提交受理(证明模型 id 有效 + 鉴权 + 参数),不等成片
650
- const taskId = await adapters.submitVideo({ key: p.apiKey, model: modelId, prompt: 'test', size: '480*832' });
651
- return { ok: !!taskId, note: taskId ? '提交受理(未等成片)' : '无 task_id' };
651
+ return { skipped: true, ok: false, note: '视频提交即可能计费,请通过“视频生成”动作验证' };
652
652
  }
653
653
  return { ok: false, note: '未知能力' };
654
654
  }
@@ -688,12 +688,18 @@ register('providers_test_model', async (ctx, args) => {
688
688
  const modelId = String(args.model_id || '').trim();
689
689
  const capability = String(args.capability || '').trim();
690
690
  if (!modelId || !cap.isKnownCapability(capability)) throw new Error('iris: 需 model_id 与合法 capability');
691
+ if (![cap.CAPABILITIES.VIDEO, cap.CAPABILITIES.TRANSCRIBE].includes(capability) && args.confirm_paid !== true) {
692
+ throw new Error('iris: 模型实测会发起真实供应商请求,请先明确确认');
693
+ }
691
694
  let result;
692
695
  try {
693
696
  result = await probeModel(ctx, p, modelId, capability);
694
697
  } catch (err) {
695
698
  result = { ok: false, note: String(err.message || err) };
696
699
  }
700
+ if (result.skipped) {
701
+ return { ok: true, skipped: true, passed: false, text: `[iris] ${modelId} · ${capability} 已跳过:${result.note}` };
702
+ }
697
703
  store.setModelVerified(p.id, modelId, capability, result);
698
704
  return {
699
705
  ok: true,
@@ -832,8 +838,8 @@ register('attachment_export', async (ctx, args) => {
832
838
  } catch (_) { /* ignore */ }
833
839
  if (!ref) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 产物:' + id);
834
840
  const stored = await att.readImage({ attachmentId: ref.attachmentId, mediaType: ref.mediaType }, null);
835
- fs.mkdirSync(uploads, { recursive: true });
836
- fs.writeFileSync(cached, Buffer.from(stored.data), { mode: 0o600 });
841
+ ensurePrivateDir(uploads);
842
+ writePrivateFile(cached, Buffer.from(stored.data));
837
843
  return { ok: true, path: cached, name: ref.name || path.basename(cached) };
838
844
  });
839
845
 
@@ -850,14 +856,14 @@ register('transcribe', async (ctx, args, { signal }) => {
850
856
  model, prompt: 'audio transcribe: ' + path.basename(audioPath)
851
857
  });
852
858
  const remoteTaskId = await tasks.submitGuard(task, async () => {
853
- const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, model, filePath: audioPath, signal });
854
- return adapters.submitTranscription({ key: provider.apiKey, model, audioUrl, signal });
859
+ const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: audioPath, signal });
860
+ return adapters.submitTranscription({ key: provider.apiKey, baseUrl: provider.baseUrl, model, audioUrl, signal });
855
861
  });
856
862
  tasks.update(task.id, { remoteTaskId });
857
863
  tasks.watch(tasks.get(task.id), {
858
864
  key: () => provider.apiKey,
859
865
  poll: async ({ key, remoteTaskId: id }) => {
860
- const r = await adapters.pollTranscriptionTask({ key, remoteTaskId: id });
866
+ const r = await adapters.pollTranscriptionTask({ key, baseUrl: provider.baseUrl, remoteTaskId: id });
861
867
  return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
862
868
  },
863
869
  intervalMs: 2500,