@mokuyoaxis/dsh-iris 0.1.0
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/.dsh/skills/iris-compose-media/SKILL.md +114 -0
- package/.dsh/skills/iris-compose-media/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +127 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/cordis.patch.yml +3 -0
- package/docs/ROADMAP.md +72 -0
- package/docs/assets/iris-wordmark.svg +25 -0
- package/docs/file-access-across-environments.md +78 -0
- package/lib/actions.js +930 -0
- package/lib/adapters.js +504 -0
- package/lib/api.js +467 -0
- package/lib/capability.js +103 -0
- package/lib/client.d.ts +5 -0
- package/lib/client.js +1187 -0
- package/lib/config.js +384 -0
- package/lib/guard.js +73 -0
- package/lib/index.js +1028 -0
- package/lib/locate.js +94 -0
- package/lib/media-probe.js +169 -0
- package/lib/media.js +172 -0
- package/lib/models.js +128 -0
- package/lib/ocr.js +80 -0
- package/lib/pixels.js +140 -0
- package/lib/render.js +138 -0
- package/lib/summarize.js +111 -0
- package/lib/tasks.js +315 -0
- package/lib/vision.js +171 -0
- package/package.json +95 -0
- package/user_guide.md +212 -0
package/lib/tasks.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Iris 任务框架 —— ai-paint/lib/tasks.js 的「宿内版」。
|
|
4
|
+
*
|
|
5
|
+
* 与 DSH 俱荣俱损的三条纪律:
|
|
6
|
+
* - 无独立进程:盯守定时器全部 unref(不拖住 DSH 退出),且随插件 Fiber 清理(stopWatchAll)
|
|
7
|
+
* - 注册表持久化 $DSH_HOME/iris/v1/tasks.json(只存元数据;产物文件在 ../outputs/)
|
|
8
|
+
* - DSH 重启后 resumePending() 接管仍在服务端运行的远程任务(百炼结果 URL 存活 24h)
|
|
9
|
+
*
|
|
10
|
+
* 对前身的改进:
|
|
11
|
+
* - 轮询容错:单次网络抖动不判死,连续 POLL_ERROR_TOLERANCE 次失败才判定任务失败
|
|
12
|
+
* - 状态机增加 canceled(工具取消信号传播)
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { irisHome } from './config.js';
|
|
17
|
+
|
|
18
|
+
const FILE = () => path.join(irisHome(), 'tasks.json');
|
|
19
|
+
/** 产物目录:$DSH_HOME/iris/v1/outputs */
|
|
20
|
+
export function outputsDir() {
|
|
21
|
+
return path.join(irisHome(), 'outputs');
|
|
22
|
+
}
|
|
23
|
+
const MAX_TASKS = 500;
|
|
24
|
+
/** 单个异步任务最长盯守时间 */
|
|
25
|
+
export const MAX_WATCH_MS = 20 * 60 * 1000;
|
|
26
|
+
/** 连续轮询失败多少次才判定任务失败(容忍瞬时网络抖动) */
|
|
27
|
+
const POLL_ERROR_TOLERANCE = 5;
|
|
28
|
+
|
|
29
|
+
let cache = null;
|
|
30
|
+
/** taskId -> NodeJS.Timeout 轮询句柄 */
|
|
31
|
+
const watchers = new Map();
|
|
32
|
+
/** taskId -> 连续轮询错误计数 */
|
|
33
|
+
const errStreak = new Map();
|
|
34
|
+
|
|
35
|
+
/* ---------------- 状态变化总线(阶段 4 SSE:落盘即通知) ---------------- */
|
|
36
|
+
const changeListeners = new Set();
|
|
37
|
+
/** 订阅任务状态变化(create/update/cancel 都会触发);返回退订函数 */
|
|
38
|
+
export function onChange(fn) {
|
|
39
|
+
changeListeners.add(fn);
|
|
40
|
+
return () => changeListeners.delete(fn);
|
|
41
|
+
}
|
|
42
|
+
function emitChange() {
|
|
43
|
+
for (const fn of [...changeListeners]) {
|
|
44
|
+
try { fn(); } catch (_) { /* 单个监听者异常不影响其余 */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function persist() {
|
|
49
|
+
fs.mkdirSync(path.dirname(FILE()), { recursive: true });
|
|
50
|
+
const tmp = FILE() + '.tmp';
|
|
51
|
+
// 0600 从创建时生效(含临时文件);rename 保留 mode,chmod 兜底不依赖 umask
|
|
52
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 1, tasks: cache.tasks }, null, 2), { mode: 0o600 });
|
|
53
|
+
fs.renameSync(tmp, FILE());
|
|
54
|
+
try {
|
|
55
|
+
fs.chmodSync(FILE(), 0o600);
|
|
56
|
+
} catch (_) {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
emitChange(); // 任何任务落盘 = 状态变了 → SSE 推送
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function load() {
|
|
63
|
+
if (cache) return cache;
|
|
64
|
+
const file = FILE();
|
|
65
|
+
if (!fs.existsSync(file)) {
|
|
66
|
+
// 文件不存在 = 首次运行,正常初始化
|
|
67
|
+
cache = { version: 1, tasks: [] };
|
|
68
|
+
persist();
|
|
69
|
+
return cache;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
73
|
+
cache = { version: 1, tasks: Array.isArray(raw.tasks) ? raw.tasks : [] };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// 文件存在但损坏:隔离,绝不静默覆盖证据
|
|
76
|
+
const backup = file + '.corrupted-' + Date.now();
|
|
77
|
+
try {
|
|
78
|
+
fs.renameSync(file, backup);
|
|
79
|
+
} catch (_) {
|
|
80
|
+
/* 隔离失败也继续 */
|
|
81
|
+
}
|
|
82
|
+
console.error('[iris] tasks.json 已损坏,已隔离为 ' + backup + ':', err && err.message);
|
|
83
|
+
cache = { version: 1, tasks: [] };
|
|
84
|
+
persist();
|
|
85
|
+
}
|
|
86
|
+
return cache;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 任务列表(新→旧),可按能力过滤,最多 50 条 */
|
|
90
|
+
export function list(cap) {
|
|
91
|
+
const filtered = cap ? load().tasks.filter((t) => t.cap === cap) : load().tasks;
|
|
92
|
+
return [...filtered].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 50);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function get(id) {
|
|
96
|
+
return load().tasks.find((t) => t.id === id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 测试/重载用:丢弃内存缓存,下次读取重新走盘 */
|
|
100
|
+
export function resetCache() {
|
|
101
|
+
cache = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function update(id, patch) {
|
|
105
|
+
const t = get(id);
|
|
106
|
+
if (!t) return undefined;
|
|
107
|
+
Object.assign(t, patch, { updatedAt: new Date().toISOString() });
|
|
108
|
+
persist();
|
|
109
|
+
return t;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function create(fields) {
|
|
113
|
+
const t = {
|
|
114
|
+
id: 't_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
115
|
+
status: 'running', // running | succeeded | failed | canceled
|
|
116
|
+
files: [], // 相对 outputs/ 的文件名
|
|
117
|
+
...fields,
|
|
118
|
+
createdAt: new Date().toISOString()
|
|
119
|
+
};
|
|
120
|
+
load().tasks.push(t);
|
|
121
|
+
// 只留最近 MAX_TASKS 条元数据
|
|
122
|
+
if (load().tasks.length > MAX_TASKS) load().tasks.splice(0, load().tasks.length - MAX_TASKS);
|
|
123
|
+
persist();
|
|
124
|
+
return t;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 全部任务记录(新→旧,不截断)——清理/孤儿扫描用 */
|
|
128
|
+
export function all() {
|
|
129
|
+
return [...load().tasks].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 删除单条任务元数据(仅终态;运行中须先 cancel)。
|
|
134
|
+
* 只删记录,产物文件留在 outputs/(媒体链接因 token 随记录消失而自然失效)。
|
|
135
|
+
* @returns {{ok:boolean, reason?:string}}
|
|
136
|
+
*/
|
|
137
|
+
export function remove(id) {
|
|
138
|
+
const t = get(id);
|
|
139
|
+
if (!t) return { ok: false, reason: '任务不存在' };
|
|
140
|
+
if (t.status === 'running') return { ok: false, reason: '运行中任务请先取消再删除' };
|
|
141
|
+
const arr = load().tasks;
|
|
142
|
+
const i = arr.findIndex((x) => x.id === id);
|
|
143
|
+
if (i >= 0) arr.splice(i, 1);
|
|
144
|
+
persist();
|
|
145
|
+
return { ok: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 批量裁剪:删除所有满足 pred(task) 的记录(运行中任务永不被 pred 命中——强制保护)。
|
|
150
|
+
* @param {(t)=>boolean} pred
|
|
151
|
+
* @returns {string[]} 被删除的任务 id
|
|
152
|
+
*/
|
|
153
|
+
export function prune(pred) {
|
|
154
|
+
const arr = load().tasks;
|
|
155
|
+
const removed = [];
|
|
156
|
+
const kept = [];
|
|
157
|
+
for (const t of arr) {
|
|
158
|
+
if (t.status !== 'running' && pred(t)) removed.push(t.id);
|
|
159
|
+
else kept.push(t);
|
|
160
|
+
}
|
|
161
|
+
if (removed.length) {
|
|
162
|
+
load().tasks = kept;
|
|
163
|
+
persist();
|
|
164
|
+
}
|
|
165
|
+
return removed;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ---------------- 盯守 ---------------- */
|
|
169
|
+
|
|
170
|
+
function stopWatch(taskId) {
|
|
171
|
+
const h = watchers.get(taskId);
|
|
172
|
+
if (h) clearTimeout(h);
|
|
173
|
+
watchers.delete(taskId);
|
|
174
|
+
errStreak.delete(taskId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 停掉全部盯守句柄 —— 插件停用/更新时由 Fiber 清理调用 */
|
|
178
|
+
export function stopWatchAll() {
|
|
179
|
+
for (const id of [...watchers.keys()]) stopWatch(id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 开始盯守一个异步任务(提交成功后调用)。
|
|
184
|
+
* @param {object} task 已 create 的任务记录(须含 remoteTaskId)
|
|
185
|
+
* @param {object} deps
|
|
186
|
+
* key(): string —— 每次轮询现取凭据
|
|
187
|
+
* poll({key, remoteTaskId}): {done,ok,urls,message} —— 协议适配器的查询函数
|
|
188
|
+
* onSuccess(task, {urls}): string[] —— 结果转存,返回相对 outputs/ 的文件名列表
|
|
189
|
+
* intervalMs?: number —— 轮询间隔(默认 2500)
|
|
190
|
+
* maxWatchMs?: number —— 盯守上限(默认 20 分钟)
|
|
191
|
+
*/
|
|
192
|
+
export function watch(task, deps) {
|
|
193
|
+
stopWatch(task.id);
|
|
194
|
+
const intervalMs = deps.intervalMs || 2500;
|
|
195
|
+
const startedAt = Date.now();
|
|
196
|
+
errStreak.set(task.id, 0);
|
|
197
|
+
let emptyOk = 0; // 状态已 SUCCEEDED 但 results 尚未填充的次数(服务端竞态)
|
|
198
|
+
|
|
199
|
+
const finish = (patch) => {
|
|
200
|
+
update(task.id, patch);
|
|
201
|
+
stopWatch(task.id);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const tick = async () => {
|
|
205
|
+
const t = get(task.id);
|
|
206
|
+
if (!t || t.status !== 'running') return stopWatch(task.id);
|
|
207
|
+
if (Date.now() - startedAt > (deps.maxWatchMs || MAX_WATCH_MS)) {
|
|
208
|
+
return finish({ status: 'failed', error: '盯守超时:服务端可能仍在继续,可稍后用任务查询确认' });
|
|
209
|
+
}
|
|
210
|
+
let r;
|
|
211
|
+
try {
|
|
212
|
+
r = await deps.poll({ key: deps.key(), remoteTaskId: t.remoteTaskId });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const n = (errStreak.get(task.id) || 0) + 1;
|
|
215
|
+
errStreak.set(task.id, n);
|
|
216
|
+
if (n >= POLL_ERROR_TOLERANCE) {
|
|
217
|
+
return finish({ status: 'failed', error: '轮询连续失败 ' + n + ' 次:' + String((err && err.message) || err) });
|
|
218
|
+
}
|
|
219
|
+
return schedule(); // 瞬时抖动,容忍
|
|
220
|
+
}
|
|
221
|
+
errStreak.set(task.id, 0);
|
|
222
|
+
if (!r.done) {
|
|
223
|
+
update(task.id, { progress: r.status || 'running' });
|
|
224
|
+
return schedule();
|
|
225
|
+
}
|
|
226
|
+
if (!r.ok) return finish({ status: 'failed', error: r.message || '任务失败' });
|
|
227
|
+
// 成功但结果 URL 未就绪:短等重试,避免空文件落袋
|
|
228
|
+
if (!(r.urls || []).length && emptyOk < 10) {
|
|
229
|
+
emptyOk++;
|
|
230
|
+
return schedule();
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const files = (await deps.onSuccess(t, r)) || [];
|
|
234
|
+
// progress 一并收尾为数值:轮询期写入的 "RUNNING" 等文本不能残留到成功后
|
|
235
|
+
finish({ status: 'succeeded', files, progress: '100%', finishedAt: new Date().toISOString() });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
finish({ status: 'failed', error: '结果转存失败:' + String((err && err.message) || err) });
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const schedule = () => {
|
|
242
|
+
const h = setTimeout(() => {
|
|
243
|
+
tick().catch(() => {});
|
|
244
|
+
}, intervalMs);
|
|
245
|
+
if (h.unref) h.unref(); // 永不拖住 DSH 进程退出
|
|
246
|
+
watchers.set(task.id, h);
|
|
247
|
+
};
|
|
248
|
+
const h0 = setTimeout(() => {
|
|
249
|
+
tick().catch(() => {});
|
|
250
|
+
}, 500);
|
|
251
|
+
if (h0.unref) h0.unref();
|
|
252
|
+
watchers.set(task.id, h0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 取消:停止盯守并标记 canceled(工具收到 abort 信号时调用) */
|
|
256
|
+
export function cancel(taskId, reason) {
|
|
257
|
+
stopWatch(taskId);
|
|
258
|
+
const t = get(taskId);
|
|
259
|
+
if (t && t.status === 'running') update(taskId, { status: 'canceled', error: reason || '已取消' });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 提交段守卫:包裹 create 之后、watch 之前的上传/提交调用。
|
|
264
|
+
* fn 抛错 → 任务标 failed(带原因)并原样重抛。
|
|
265
|
+
* 没有它,提交失败会留下 status=running 且无 remoteTaskId 的孤儿记录:
|
|
266
|
+
* 没有盯守者、resumePending 也无法接管,永远占着「运行中」。
|
|
267
|
+
*/
|
|
268
|
+
export async function submitGuard(task, fn) {
|
|
269
|
+
try {
|
|
270
|
+
return await fn();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
const canceled = err && (err.name === 'AbortError' || err.code === 'ABORT_ERR');
|
|
273
|
+
update(task.id, {
|
|
274
|
+
status: canceled ? 'canceled' : 'failed',
|
|
275
|
+
error: canceled ? '提交已取消' : ('提交失败:' + String((err && err.message) || err))
|
|
276
|
+
});
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 重启恢复:接管仍在运行的远程任务。DSH 重启期间百炼任务在服务端继续跑,
|
|
283
|
+
* 结果 URL 存活 24h,重启后重新盯守即可落袋。
|
|
284
|
+
* @param {(task) => deps|null} depsFor 返回盯守依赖;null 表示无法接管(如供应商已删除)
|
|
285
|
+
* @returns {string[]} 成功接管的任务 id
|
|
286
|
+
*/
|
|
287
|
+
export function resumePending(depsFor) {
|
|
288
|
+
const resumed = [];
|
|
289
|
+
for (const t of [...load().tasks]) {
|
|
290
|
+
try {
|
|
291
|
+
if (t.status !== 'running') continue;
|
|
292
|
+
if (!t.remoteTaskId) {
|
|
293
|
+
// 孤儿记录兜底清理:create 已落盘但提交抛错/进程死亡,remoteTaskId 从未写入。
|
|
294
|
+
// 无盯守者、无远程 id 可接管 → 启动时标 failed,不再永远占着「运行中」。
|
|
295
|
+
update(t.id, { status: 'failed', error: '提交未完成(无远程任务 id),启动时标失败清理' });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (Date.now() - new Date(t.createdAt).getTime() > MAX_WATCH_MS) {
|
|
299
|
+
update(t.id, { status: 'failed', error: '重启前任务已超时,请重新发起' });
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const deps = depsFor(t);
|
|
303
|
+
if (!deps) {
|
|
304
|
+
update(t.id, { status: 'failed', error: '无法恢复:对应供应商不可用' });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
watch(t, deps);
|
|
308
|
+
resumed.push(t.id);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// 单条恢复失败只标死这一条,绝不让恢复循环(乃至宿主启动)陪葬
|
|
311
|
+
update(t.id, { status: 'failed', error: '恢复异常:' + String((err && err.message) || err) });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return resumed;
|
|
315
|
+
}
|
package/lib/vision.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Iris 视觉后端抽象(阶段 1)。
|
|
4
|
+
*
|
|
5
|
+
* 对齐 RESEARCH 4.1b 的 vision-mix 经验:把"识图"拆成「VisionBackend 接口 +
|
|
6
|
+
* 两个实现」,让自持栈与 DSH 全局模型可替换、按序降级。
|
|
7
|
+
* - SelfStackVisionBackend:自持栈(OpenAI 兼容 /chat/completions,qwen-vl)
|
|
8
|
+
* - GlobalLlmVisionBackend:DSH 全局视觉模型(ctx.llm.stream())
|
|
9
|
+
*
|
|
10
|
+
* 调用方(工具)不感知底层:buildVisionBackends 按 provider 顺序组装,
|
|
11
|
+
* askWithBackends 依次尝试、保留每个后端的失败现场。
|
|
12
|
+
*/
|
|
13
|
+
import * as adapters from './adapters.js';
|
|
14
|
+
|
|
15
|
+
/** 后端接口约定:id / kind / model 只读,analyze(request) → Promise<string> */
|
|
16
|
+
|
|
17
|
+
export class SelfStackVisionBackend {
|
|
18
|
+
constructor({ provider }) {
|
|
19
|
+
this.provider = provider;
|
|
20
|
+
}
|
|
21
|
+
get id() {
|
|
22
|
+
return this.provider.id;
|
|
23
|
+
}
|
|
24
|
+
get kind() {
|
|
25
|
+
return 'selfstack';
|
|
26
|
+
}
|
|
27
|
+
get model() {
|
|
28
|
+
return this.provider.visionModel || 'qwen-vl-plus';
|
|
29
|
+
}
|
|
30
|
+
async analyze({ question, imageDataUrl, signal }) {
|
|
31
|
+
return adapters.visionStream({
|
|
32
|
+
key: this.provider.apiKey,
|
|
33
|
+
baseUrl: this.provider.baseUrl,
|
|
34
|
+
model: this.model,
|
|
35
|
+
prompt: question,
|
|
36
|
+
imageDataUrl,
|
|
37
|
+
signal
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class GlobalLlmVisionBackend {
|
|
43
|
+
constructor({ llm }) {
|
|
44
|
+
this.llm = llm;
|
|
45
|
+
}
|
|
46
|
+
get id() {
|
|
47
|
+
return 'global';
|
|
48
|
+
}
|
|
49
|
+
get kind() {
|
|
50
|
+
return 'global';
|
|
51
|
+
}
|
|
52
|
+
get model() {
|
|
53
|
+
return '全局视觉模型';
|
|
54
|
+
}
|
|
55
|
+
async analyze({ question, ref, signal }) {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
for await (const chunk of this.llm.stream({
|
|
58
|
+
sessionId: undefined,
|
|
59
|
+
provider: undefined,
|
|
60
|
+
model: undefined,
|
|
61
|
+
signal,
|
|
62
|
+
messages: [
|
|
63
|
+
{
|
|
64
|
+
role: 'user',
|
|
65
|
+
content: [
|
|
66
|
+
{ type: 'text', text: question },
|
|
67
|
+
{ type: 'image', attachment: ref }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
})) {
|
|
72
|
+
const t = chunk && chunk.delta;
|
|
73
|
+
if (typeof t === 'string' && t) chunks.push(t);
|
|
74
|
+
if (chunks.join('').length > 6000) break;
|
|
75
|
+
}
|
|
76
|
+
return chunks.join('').trim();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 按 provider 顺序组装视觉后端链(自持栈在前,全局模型兜底在后)。
|
|
82
|
+
* 只接受已声明/推断 vision 能力的 provider(由调用方用 capability 过滤)。
|
|
83
|
+
* @param {object} ctx DSH ctx(用于取 llm 服务)
|
|
84
|
+
* @param {{providers: Array, model?: string}} opts model 覆盖全部自持栈的 vision 模型
|
|
85
|
+
* @returns {Array} 有序后端
|
|
86
|
+
*/
|
|
87
|
+
export function buildVisionBackends(ctx, { providers, model }) {
|
|
88
|
+
const backends = [];
|
|
89
|
+
for (const p of providers || []) {
|
|
90
|
+
if (p && p.type === 'openai') {
|
|
91
|
+
const provider = model ? { ...p, visionModel: model } : p;
|
|
92
|
+
backends.push(new SelfStackVisionBackend({ provider }));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : undefined;
|
|
96
|
+
if (llm && typeof llm.stream === 'function') backends.push(new GlobalLlmVisionBackend({ llm }));
|
|
97
|
+
return backends;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 依次尝试后端链,返回首个非空回答。
|
|
102
|
+
* @param {Array} backends 有序后端
|
|
103
|
+
* @param {object} request {question, imageDataUrl?, ref?, signal?}
|
|
104
|
+
* @returns {Promise<{answer:string, via:string, model:string, backendId:string, errors:Array}>}
|
|
105
|
+
* @throws 全部后端失败 → Error(带 errors 现场)
|
|
106
|
+
*/
|
|
107
|
+
export async function askWithBackends(backends, request) {
|
|
108
|
+
const errors = [];
|
|
109
|
+
for (const b of backends) {
|
|
110
|
+
try {
|
|
111
|
+
const answer = await b.analyze(request);
|
|
112
|
+
if (answer && answer.trim()) {
|
|
113
|
+
return { answer: answer.trim(), via: b.kind, model: b.model, backendId: b.id, errors };
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
errors.push({
|
|
117
|
+
backendId: b.id,
|
|
118
|
+
kind: b.kind,
|
|
119
|
+
category: err && err.category,
|
|
120
|
+
status: err && err.status,
|
|
121
|
+
message: String((err && err.message) || err)
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const err = new Error('iris: 视觉模型不可用——自持栈与全局视觉模型都失败了');
|
|
126
|
+
err.errors = errors;
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ---------------- 视觉能力测试("test it, don't guess it") ---------------- */
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 固定红色测试图(1x1 纯红 PNG,真实生成)。用于验证模型真的能看图,
|
|
134
|
+
* 而不是只看 HTTP 返回 200 就声称支持 vision。
|
|
135
|
+
*/
|
|
136
|
+
export const RED_TEST_IMAGE =
|
|
137
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
|
|
138
|
+
|
|
139
|
+
/** 期望的回答语义:必须提到红色(中文"红"或英文 red),才判定具备视觉能力 */
|
|
140
|
+
const RED_HINTS = /红|red/i;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 视觉能力测试:向后端发固定红色测试图并提问,验证回答是否识别出红色。
|
|
144
|
+
* 用于「测试并启用」流程(对齐 RESEARCH 4.5b 的 vision-mix 经验)。
|
|
145
|
+
* @param {object} backend VisionBackend 实例(有 analyze 方法)
|
|
146
|
+
* @param {{timeoutMs?:number, signal?:AbortSignal}} opts
|
|
147
|
+
* @returns {Promise<{ok:boolean, answer:string, error?:string, timedOut?:boolean}>}
|
|
148
|
+
*/
|
|
149
|
+
export async function testVisionCapability(backend, { timeoutMs = 10000, signal } = {}) {
|
|
150
|
+
let timer;
|
|
151
|
+
const timeout = new Promise((_, reject) => {
|
|
152
|
+
timer = setTimeout(() => reject(Object.assign(new Error('能力测试超时'), { timedOut: true })), timeoutMs);
|
|
153
|
+
if (timer.unref) timer.unref();
|
|
154
|
+
});
|
|
155
|
+
try {
|
|
156
|
+
const answer = await Promise.race([
|
|
157
|
+
backend.analyze({
|
|
158
|
+
question: '这张图片是什么颜色?请只回答颜色名称(中文)。',
|
|
159
|
+
imageDataUrl: RED_TEST_IMAGE,
|
|
160
|
+
signal
|
|
161
|
+
}),
|
|
162
|
+
timeout
|
|
163
|
+
]);
|
|
164
|
+
const ok = RED_HINTS.test(String(answer || ''));
|
|
165
|
+
return { ok, answer: String(answer || '') };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return { ok: false, answer: '', error: String((err && err.message) || err), timedOut: !!err.timedOut };
|
|
168
|
+
} finally {
|
|
169
|
+
if (timer) clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mokuyoaxis/dsh-iris",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Give DeepSeek Harness eyes and hands: multi-provider media generation, vision routing, and an integrated Iris workbench.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": {
|
|
10
|
+
"types": "./lib/client.d.ts",
|
|
11
|
+
"default": "./lib/client.js"
|
|
12
|
+
},
|
|
13
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"user_guide.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"docs",
|
|
23
|
+
".dsh/skills/iris-verify-ui",
|
|
24
|
+
".dsh/skills/iris-compose-media"
|
|
25
|
+
],
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
},
|
|
30
|
+
"client": {
|
|
31
|
+
"platform": "web",
|
|
32
|
+
"inject": [
|
|
33
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
34
|
+
"@deepseek-ai/dsh-client-locale",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"deepseek-harness",
|
|
43
|
+
"dsh-plugin",
|
|
44
|
+
"vision",
|
|
45
|
+
"image-generation",
|
|
46
|
+
"dashscope",
|
|
47
|
+
"tts"
|
|
48
|
+
],
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20.9.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "for f in tests/*.mjs; do node \"$f\" || exit 1; done",
|
|
55
|
+
"lint": "node tests/lint.mjs"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@deepseek-ai/dsh-client-locale": "*",
|
|
59
|
+
"@deepseek-ai/dsh-client-runtime": "*",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-conversation": "*",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-settings": "*",
|
|
62
|
+
"@deepseek-ai/dsh-client-ui-slots": "*"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"@deepseek-ai/dsh-client-runtime": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@deepseek-ai/dsh-client-locale": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-slots": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-conversation": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-settings": {
|
|
78
|
+
"optional": true
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"dependencies": {
|
|
82
|
+
"sharp": "^0.35.4"
|
|
83
|
+
},
|
|
84
|
+
"publishConfig": {
|
|
85
|
+
"access": "public"
|
|
86
|
+
},
|
|
87
|
+
"repository": {
|
|
88
|
+
"type": "git",
|
|
89
|
+
"url": "git+https://github.com/mokuyoaxis/dsh-iris.git"
|
|
90
|
+
},
|
|
91
|
+
"homepage": "https://github.com/mokuyoaxis/dsh-iris#readme",
|
|
92
|
+
"bugs": {
|
|
93
|
+
"url": "https://github.com/mokuyoaxis/dsh-iris/issues"
|
|
94
|
+
}
|
|
95
|
+
}
|