@mokuyoaxis/dsh-iris 0.1.2 → 0.1.3

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/lib/api.js CHANGED
@@ -12,12 +12,14 @@
12
12
  */
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
+ import crypto from 'node:crypto';
15
16
  import { irisHome, capabilitiesOf, onChange as onConfigChange } from './config.js';
16
17
  import { mimeOf, mediaUrl } from './media.js';
17
18
  import * as tasks from './tasks.js';
18
19
  import { runAction } from './actions.js';
19
20
  import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
20
21
  import { inferMediaProtocol } from './provider-protocol.js';
22
+ import { deriveUserState } from './task-semantics.js';
21
23
  import { optimizePrompt } from './prompt-optimizer.js';
22
24
  import {
23
25
  importPromptOptimizerConfig,
@@ -75,6 +77,21 @@ function mediaOf(task) {
75
77
  }));
76
78
  }
77
79
 
80
+ const V2_FACT_FIELDS = ['phase', 'acceptance', 'watchState', 'outcome', 'deliveryState', 'cancelState'];
81
+
82
+ function isTaskV2(t) {
83
+ return Boolean(t && t.schemaVersion === 2 && V2_FACT_FIELDS.every((field) => Object.prototype.hasOwnProperty.call(t, field)));
84
+ }
85
+
86
+ /** 旧记录保持原四态;只有完整 v2 记录才使用事实轴派生用户态。 */
87
+ function taskUserState(t) {
88
+ if (isTaskV2(t)) return deriveUserState(t);
89
+ if (t && t.status === 'succeeded') return 'succeeded';
90
+ if (t && t.status === 'failed') return 'failed';
91
+ if (t && t.status === 'canceled') return 'canceled';
92
+ return 'running';
93
+ }
94
+
78
95
  function taskPublic(t) {
79
96
  const elapsedMs =
80
97
  t.elapsedMs ||
@@ -83,6 +100,9 @@ function taskPublic(t) {
83
100
  id: t.id,
84
101
  cap: t.cap || '',
85
102
  status: t.status || 'running',
103
+ userState: taskUserState(t),
104
+ schemaVersion: Number(t.schemaVersion || 1),
105
+ revision: Number(t.revision || 0),
86
106
  progress: t.progress || '',
87
107
  model: t.model || '',
88
108
  providerName: t.providerName || '',
@@ -94,6 +114,8 @@ function taskPublic(t) {
94
114
  saved: !!t.saved,
95
115
  expired: !!t.expired,
96
116
  mode: t.mode || '',
117
+ retryOf: t.retryOf || '',
118
+ attentionDisposition: tasks.attentionDisposition(t),
97
119
  files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
98
120
  media: mediaOf(t)
99
121
  };
@@ -110,21 +132,32 @@ function loadProviders() {
110
132
  return Array.isArray(cache && cache.providers) ? cache.providers : [];
111
133
  }
112
134
 
113
- /** 构建完整工作台状态(纯 JSON,可序列化) */
135
+ const STATE_EPOCH = crypto.randomUUID();
136
+ let stateRevision = 0;
137
+
138
+ /** 构建完整工作台状态(纯 JSON,可序列化);序号用于客户端拒绝迟到快照。 */
114
139
  export function buildState() {
115
- const tasks = loadTasks();
140
+ const taskRecords = loadTasks();
116
141
  const providers = loadProviders();
117
142
 
118
- const all = [...tasks].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
119
- const running = all.filter((t) => t.status === 'running' && !t.expired);
120
- const terminal = all.filter((t) => t.status !== 'running');
143
+ const all = [...taskRecords].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
144
+ const stateOf = (t) => taskUserState(t);
145
+ const needsAttention = (t) => ['watching_paused', 'needs_attention', 'artifact_unavailable'].includes(stateOf(t));
146
+ const acknowledged = (t) => tasks.attentionDisposition(t).status === 'acknowledged';
147
+ const running = all.filter((t) => ['queued', 'running'].includes(stateOf(t)) && !t.expired);
148
+ const attention = all.filter((t) => needsAttention(t) && !acknowledged(t) && !t.expired);
149
+ const terminal = all.filter((t) => ['succeeded', 'failed', 'canceled'].includes(stateOf(t))
150
+ || (needsAttention(t) && acknowledged(t)) || t.expired);
121
151
  const recent = terminal.slice(0, 100).map(taskPublic);
122
152
 
123
153
  return {
154
+ stateEpoch: STATE_EPOCH,
155
+ stateRevision: ++stateRevision,
124
156
  providers: providers.map(providerPublic),
125
157
  tasks: {
126
158
  recent,
127
159
  recentTotal: terminal.length, // 泡泡「查看全部 (N)」计数用(不受 slice 截断)
160
+ attention: attention.slice(0, 20).map(taskPublic),
128
161
  running: running.slice(0, 10).map(taskPublic)
129
162
  }
130
163
  };
@@ -138,15 +171,44 @@ export function sseClientCount() {
138
171
  }
139
172
 
140
173
  const sseClients = new Set(); // 活跃 res(长连接)
174
+ const sseBlocked = new Set(); // 背压中的连接
175
+ const sseLatest = new WeakMap(); // 每个慢连接只保留最新状态
176
+ const sseDrainBound = new WeakSet();
141
177
  let sseHeartbeat = null; // 心跳定时器(15s,防代理/浏览器空闲断连)
142
178
  let sseThrottleTimer = null; // 推送节流(400ms 合并,防盯守高频 update 刷屏)
143
179
  let ssePending = false;
144
180
  let sseBound = false;
145
181
 
182
+ function bindSseDrain(res) {
183
+ sseBlocked.add(res);
184
+ if (sseDrainBound.has(res)) return;
185
+ sseDrainBound.add(res);
186
+ res.once('drain', () => {
187
+ sseDrainBound.delete(res);
188
+ sseBlocked.delete(res);
189
+ const latest = sseLatest.get(res);
190
+ sseLatest.delete(res);
191
+ if (latest !== undefined) pushSse(res, latest);
192
+ });
193
+ }
194
+
146
195
  function pushSse(res, data) {
147
196
  if (!res || res.writableEnded || res.destroyed) return;
197
+ if (sseBlocked.has(res)) {
198
+ sseLatest.set(res, data);
199
+ return;
200
+ }
201
+ try {
202
+ if (res.write(`data: ${data}\n\n`) === false) bindSseDrain(res);
203
+ } catch (_) {
204
+ /* 连接已死,close 事件会清理 */
205
+ }
206
+ }
207
+
208
+ function pushSseComment(res, comment) {
209
+ if (!res || res.writableEnded || res.destroyed || sseBlocked.has(res)) return;
148
210
  try {
149
- res.write(`data: ${data}\n\n`);
211
+ if (res.write(`: ${comment}\n\n`) === false) bindSseDrain(res);
150
212
  } catch (_) {
151
213
  /* 连接已死,close 事件会清理 */
152
214
  }
@@ -177,7 +239,7 @@ function scheduleBroadcast() {
177
239
  function ensureHeartbeat() {
178
240
  if (sseHeartbeat) return;
179
241
  sseHeartbeat = setInterval(() => {
180
- for (const res of [...sseClients]) pushSse(res, ': ping'); // 注释行保持连接活跃
242
+ for (const res of [...sseClients]) pushSseComment(res, 'ping'); // 注释帧不覆盖背压期间的最新状态
181
243
  if (!sseClients.size) stopHeartbeat();
182
244
  }, 15000);
183
245
  if (sseHeartbeat && sseHeartbeat.unref) sseHeartbeat.unref();
@@ -239,6 +301,8 @@ function serveSse(res) {
239
301
  pushSse(res, JSON.stringify(buildState())); // 立即推当前状态
240
302
  const onClose = () => {
241
303
  sseClients.delete(res);
304
+ sseBlocked.delete(res);
305
+ sseLatest.delete(res);
242
306
  if (!sseClients.size) {
243
307
  stopHeartbeat();
244
308
  if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
@@ -493,6 +557,10 @@ function serveTaskDetail(taskId, method, res) {
493
557
  id: t.id,
494
558
  cap: t.cap || '',
495
559
  status: t.status || '',
560
+ userState: taskUserState(t),
561
+ schemaVersion: Number(t.schemaVersion || 1),
562
+ revision: Number(t.revision || 0),
563
+ ...(isTaskV2(t) ? Object.fromEntries(V2_FACT_FIELDS.map((field) => [field, t[field]])) : {}),
496
564
  progress: t.progress || '',
497
565
  model: t.model || '',
498
566
  providerName: t.providerName || '',
@@ -503,6 +571,11 @@ function serveTaskDetail(taskId, method, res) {
503
571
  error: t.error || '',
504
572
  mode: t.mode || '',
505
573
  remoteTaskId: t.remoteTaskId || '',
574
+ retryOf: t.retryOf || '',
575
+ manualRetries: Array.isArray(t.manualRetries) ? t.manualRetries.filter((item) => item && item.taskId).map((item) => ({
576
+ taskId: String(item.taskId), createdAt: item.createdAt || ''
577
+ })) : [],
578
+ attentionDisposition: tasks.attentionDisposition(t),
506
579
  files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
507
580
  media: mediaOf(t),
508
581
  attachments: Array.isArray(t.attachments) ? t.attachments.map((a) => ({
package/lib/client.js CHANGED
@@ -37,6 +37,7 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
37
37
  '.iris-wb-badge { font-size: 11px; padding: 1px 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2, #32323c); }',
38
38
  '.iris-wb-badge.ok { color: var(--dsw-alias-state-success-primary, #6fcf6f); }',
39
39
  '.iris-wb-badge.err { color: var(--dsw-alias-state-error-primary, #e06c6c); }',
40
+ '.iris-wb-badge.warn { color: var(--dsw-alias-state-warn-primary, #d9a941); }',
40
41
  '.iris-wb-progress { margin-top: 6px; }',
41
42
  '.iris-wb-bar { height: 5px; border-radius: 3px; background: var(--dsw-alias-bg-layer-2, #32323c); overflow: hidden; }',
42
43
  '.iris-wb-bar i { display: block; height: 100%; background: var(--dsw-alias-brand-primary, #7aa2f7); }',
@@ -252,6 +253,8 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
252
253
  if (sharedState) setState(sharedState);
253
254
  if (!sharedTimer && !sharedSource) {
254
255
  function apply(data) {
256
+ if (sharedState && data && sharedState.stateEpoch && data.stateEpoch === sharedState.stateEpoch
257
+ && Number(data.stateRevision || 0) <= Number(sharedState.stateRevision || 0)) return;
255
258
  sharedState = data;
256
259
  for (var i = 0; i < sharedListeners.length; i++) {
257
260
  sharedListeners[i](data);
@@ -347,10 +350,38 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
347
350
  return '更早';
348
351
  }
349
352
 
353
+ var TASK_STATE_LABELS = {
354
+ queued: '排队中', running: '运行中', watching_paused: '观察暂停', needs_attention: '需要确认',
355
+ artifact_unavailable: '结果待取回', succeeded: '成功', failed: '失败', canceled: '已取消'
356
+ };
357
+ function taskState(task) {
358
+ if (task && task.userState) return task.userState;
359
+ if (task && task.status === 'succeeded') return 'succeeded';
360
+ if (task && task.status === 'failed') return 'failed';
361
+ if (task && task.status === 'canceled') return 'canceled';
362
+ return 'running';
363
+ }
364
+ function taskStateLabel(task) {
365
+ var state = taskState(task);
366
+ return TASK_STATE_LABELS[state] || state;
367
+ }
368
+ function taskStateClass(task) {
369
+ var state = taskState(task);
370
+ if (state === 'succeeded') return 'ok';
371
+ if (state === 'failed') return 'err';
372
+ if (state === 'watching_paused' || state === 'needs_attention' || state === 'artifact_unavailable') return 'warn';
373
+ return '';
374
+ }
375
+ function attentionDispositionLabel(task) {
376
+ var disposition = task && task.attentionDisposition;
377
+ if (!disposition || disposition.status !== 'acknowledged') return '';
378
+ return disposition.reason === 'retried' ? '已通过重试处理' : '提醒已读';
379
+ }
380
+
350
381
  /* 紧凑任务行:泡泡「任务」标签用,单行摘要 */
351
382
  function taskRowMini(task, selected, onSelect) {
352
- var st = task.status === 'succeeded' ? 'ok' : task.status === 'failed' ? 'err' : '';
353
- var label = task.status === 'succeeded' ? '成功' : task.status === 'failed' ? '失败' : task.status === 'canceled' ? '取消' : (task.progress || '运行中');
383
+ var st = taskStateClass(task);
384
+ var label = taskStateLabel(task);
354
385
  return React.createElement('div', {
355
386
  key: task.id, className: 'iris-task-mini' + (selected ? ' selected' : ''),
356
387
  onClick: function () { onSelect(selected ? null : task.id); }, title: '点击展开详情'
@@ -361,6 +392,47 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
361
392
  React.createElement('span', { className: 'iris-wb-muted' }, task.createdAt ? new Date(task.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''));
362
393
  }
363
394
 
395
+ function TaskRecoveryActions({ detail }) {
396
+ var pair = React.useState('');
397
+ var note = pair[0];
398
+ var setNote = pair[1];
399
+ var busyPair = React.useState(false);
400
+ var busy = busyPair[0];
401
+ var setBusy = busyPair[1];
402
+ var state = taskState(detail);
403
+ var attentionState = ['watching_paused', 'needs_attention', 'artifact_unavailable'].indexOf(state) >= 0;
404
+ var acknowledged = detail.attentionDisposition && detail.attentionDisposition.status === 'acknowledged';
405
+ var canAcknowledge = detail.schemaVersion === 2 && attentionState && !acknowledged;
406
+ var canRestore = detail.schemaVersion === 2 && attentionState && acknowledged;
407
+ var canReobserve = detail.schemaVersion === 2 && !acknowledged && detail.acceptance === 'accepted' && detail.remoteTaskId
408
+ && detail.outcome !== 'succeeded' && detail.outcome !== 'failed' && detail.outcome !== 'canceled';
409
+ var canRedeliver = detail.schemaVersion === 2 && !acknowledged && state === 'artifact_unavailable' && detail.remoteTaskId;
410
+ var canRetry = detail.schemaVersion === 2 && !acknowledged && ['needs_attention', 'failed', 'canceled'].indexOf(state) >= 0
411
+ && (detail.cap === 'image' || (detail.cap === 'video' && detail.mode === 't2v'));
412
+ if (!canAcknowledge && !canRestore && !canReobserve && !canRedeliver && !canRetry) return null;
413
+ function run(action, body, confirmMessage) {
414
+ if (confirmMessage && !window.confirm(confirmMessage)) return;
415
+ setBusy(true);
416
+ setNote('处理中…');
417
+ postAction(action, body).then(function (r) {
418
+ setNote(r.ok ? ((r.d && r.d.text) || '操作已接受') : ('失败:' + ((r.d && r.d.error) || '未知错误')));
419
+ }).catch(function (error) { setNote('失败:' + String((error && error.message) || error)); })
420
+ .finally(function () { setBusy(false); });
421
+ }
422
+ return React.createElement('div', { className: 'iris-wb-row', onClick: function (e) { e.stopPropagation(); } },
423
+ canReobserve ? React.createElement('button', { className: 'iris-pm-btn', disabled: busy, onClick: function () { run('task_reobserve', { task_id: detail.id }); } }, '重新观察') : null,
424
+ canRedeliver ? React.createElement('button', { className: 'iris-pm-btn', disabled: busy, onClick: function () { run('task_redeliver', { task_id: detail.id }); } }, '重新交付') : null,
425
+ canRetry ? React.createElement('button', { className: 'iris-pm-btn danger', disabled: busy, onClick: function () {
426
+ run('task_manual_retry', { task_id: detail.id, confirm_duplicate_charge: true },
427
+ '远端任务可能已经产生,本次会创建新的生成任务,可能重复计费。确认知情重试?');
428
+ } }, '知情重试') : null,
429
+ canAcknowledge ? React.createElement('button', { className: 'iris-pm-btn', disabled: busy,
430
+ onClick: function () { run('task_ack_attention', { task_id: detail.id }); } }, '标为已读') : null,
431
+ canRestore ? React.createElement('button', { className: 'iris-pm-btn', disabled: busy,
432
+ onClick: function () { run('task_restore_attention', { task_id: detail.id }); } }, '恢复提醒') : null,
433
+ note ? React.createElement('span', { className: 'iris-clean-note' }, note) : null);
434
+ }
435
+
364
436
  /* ---- 任务详情抽屉:点击任务卡片行展开 ---- */
365
437
  function TaskDetailDrawer({ taskId }) {
366
438
  var detail = useTaskDetail(taskId);
@@ -370,6 +442,15 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
370
442
  var el = [];
371
443
  el.push(React.createElement('div', { key: 'k', className: 'iris-wb-k' }, '提示词'));
372
444
  el.push(React.createElement('div', { key: 'p', className: 'iris-wb-prompt' }, detail.prompt || '(空)'));
445
+ if (Number(detail.schemaVersion) === 1) {
446
+ el.push(React.createElement('div', { key: 'legacy', className: 'iris-clean-note' },
447
+ '旧版任务记录 · 仅支持查看。由于缺少可靠的远端受理与交付事实,不提供重新观察、重新交付或知情重试;0.1.3 新任务将使用可信任务语义。'));
448
+ }
449
+ var dispositionLabel = attentionDispositionLabel(detail);
450
+ if (dispositionLabel) {
451
+ var related = detail.attentionDisposition.relatedTaskId ? ' · 关联任务 ' + detail.attentionDisposition.relatedTaskId : '';
452
+ el.push(React.createElement('div', { key: 'attention-disposition', className: 'iris-clean-note' }, dispositionLabel + related));
453
+ }
373
454
  if (detail.error) {
374
455
  el.push(React.createElement('div', { key: 'ek', className: 'iris-wb-k' }, '错误'));
375
456
  el.push(React.createElement('div', { key: 'e', className: 'iris-wb-err' }, detail.error));
@@ -377,11 +458,13 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
377
458
  var kv = [
378
459
  ['ID', detail.id],
379
460
  ['能力', detail.cap],
380
- ['状态', detail.status],
461
+ ['状态', taskStateLabel(detail)],
381
462
  ['模型', detail.model],
382
463
  ['供应商', detail.providerName],
383
464
  ['模式', detail.mode || '—'],
384
465
  ['远端任务', detail.remoteTaskId || '—'],
466
+ ['来源任务', detail.retryOf || '—'],
467
+ ['后续重试', (detail.manualRetries || []).map(function (item) { return item.taskId; }).join('、') || '—'],
385
468
  ['发起', detail.createdAt ? fmtTime(detail.createdAt) : '—'],
386
469
  ['完成', detail.finishedAt ? fmtTime(detail.finishedAt) : '—'],
387
470
  ['耗时', detail.elapsedMs ? fmtElapsed(detail.elapsedMs) : '—']
@@ -418,24 +501,25 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
418
501
  return React.createElement('span', { key: a.attachmentId, className: 'iris-wb-kv' }, a.attachmentId + (a.file ? ' (' + a.file + ')' : ''));
419
502
  })));
420
503
  }
504
+ el.push(React.createElement(TaskRecoveryActions, { key: 'recovery', detail: detail }));
421
505
  return React.createElement('div', { className: 'iris-wb-drawer' }, ...el);
422
506
  }
423
507
 
424
508
  function taskRow(task, isRunning, selected, onSelect) {
425
- var badge;
426
- if (task.status === 'succeeded') {
427
- badge = React.createElement('span', { className: 'iris-wb-badge ok' }, '成功');
428
- } else if (task.status === 'failed') {
429
- badge = React.createElement('span', { className: 'iris-wb-badge err' }, '失败');
430
- } else {
431
- badge = React.createElement('span', { className: 'iris-wb-badge' }, task.status);
432
- }
509
+ var stateClass = taskStateClass(task);
510
+ var badge = React.createElement('span', { className: 'iris-wb-badge' + (stateClass ? ' ' + stateClass : '') }, taskStateLabel(task));
433
511
  var cells = [
434
512
  React.createElement('span', { className: 'iris-wb-kv' }, task.cap),
435
513
  badge,
436
514
  React.createElement('span', { className: 'iris-wb-kv' }, task.model || ''),
437
515
  React.createElement('span', { className: 'iris-wb-muted' }, String(task.prompt || '').slice(0, 60))
438
516
  ];
517
+ if (Number(task.schemaVersion) === 1) {
518
+ cells.push(React.createElement('span', { className: 'iris-wb-badge', title: '旧版记录缺少可靠的远端受理与交付事实,仅支持查看' }, '旧任务 · 只读'));
519
+ }
520
+ var dispositionLabel = attentionDispositionLabel(task);
521
+ if (dispositionLabel) cells.push(React.createElement('span', { className: 'iris-wb-badge ok' }, dispositionLabel));
522
+ if (task.retryOf) cells.push(React.createElement('span', { className: 'iris-wb-badge' }, '重试任务'));
439
523
  if (task.error) cells.push(React.createElement('span', { className: 'iris-wb-badge err' }, String(task.error).slice(0, 40)));
440
524
  var links = (task.media || []).map(function (m) {
441
525
  return React.createElement('a', {
@@ -1061,6 +1145,7 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
1061
1145
  var state = useIrisState(5000);
1062
1146
  var running = (state && state.tasks && state.tasks.running) || [];
1063
1147
  var recent = (state && state.tasks && state.tasks.recent) || [];
1148
+ var attention = (state && state.tasks && state.tasks.attention) || [];
1064
1149
  var selPair = React.useState(null);
1065
1150
  var selectedTask = selPair[0];
1066
1151
  var setSelectedTask = selPair[1];
@@ -1081,6 +1166,10 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
1081
1166
  React.createElement('div', { className: 'iris-wb-box' },
1082
1167
  running.length ? running.map(function (t) { return taskRow(t, true, selectedTask === t.id, onSelect); })
1083
1168
  : React.createElement('div', { className: 'iris-wb-empty' }, '暂无进行中任务')),
1169
+ attention.length ? React.createElement('div', {},
1170
+ React.createElement('div', { className: 'iris-wb-sec' }, '需要处理'),
1171
+ React.createElement('div', { className: 'iris-wb-box' },
1172
+ attention.map(function (t) { return taskRow(t, false, selectedTask === t.id, onSelect); }))) : null,
1084
1173
  React.createElement('div', { className: 'iris-wb-sec' }, '历史任务'),
1085
1174
  React.createElement(CleanupBar, {}),
1086
1175
  React.createElement(HistoryBrowser, { recent: recent, selected: selectedTask, onSelect: onSelect }),
@@ -1093,6 +1182,7 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
1093
1182
  var state = useIrisState(5000);
1094
1183
  var running = (state && state.tasks && state.tasks.running) || [];
1095
1184
  var recent = (state && state.tasks && state.tasks.recent) || [];
1185
+ var attention = (state && state.tasks && state.tasks.attention) || [];
1096
1186
  var recentTotal = (state && state.tasks && state.tasks.recentTotal) || recent.length;
1097
1187
  var cards = useBubbleCards()[0];
1098
1188
  var tabPair = React.useState('tasks');
@@ -1104,7 +1194,7 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
1104
1194
  function onSelect(id) { setSelectedTask(id); }
1105
1195
  var body;
1106
1196
  if (tab === 'tasks') {
1107
- // 快捷面板只承载当前任务与可直接查看的成功产物;失败/取消历史留在完整工作台。
1197
+ // 快捷面板承载当前任务、需要处理的异常事实与可直接查看的成功产物。
1108
1198
  var succeeded = recent.filter(function (t) {
1109
1199
  return t.status === 'succeeded' && Array.isArray(t.media) && t.media.length > 0;
1110
1200
  });
@@ -1113,6 +1203,9 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
1113
1203
  running.length ? React.createElement('div', {},
1114
1204
  React.createElement('div', { className: 'iris-wb-sec' }, '运行中'),
1115
1205
  running.map(function (t) { return taskRow(t, true, selectedTask === t.id, onSelect); })) : null,
1206
+ attention.length ? React.createElement('div', {},
1207
+ React.createElement('div', { className: 'iris-wb-sec' }, '需要处理'),
1208
+ attention.slice(0, 4).map(function (t) { return taskRowMini(t, selectedTask === t.id, onSelect); })) : null,
1116
1209
  React.createElement('div', { className: 'iris-wb-sec' }, '最近完成'),
1117
1210
  shown.length ? shown.map(function (t) { return taskRowMini(t, selectedTask === t.id, onSelect); })
1118
1211
  : React.createElement('div', { className: 'iris-wb-empty' }, '尚无成功产物'),
@@ -1379,6 +1472,7 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
1379
1472
  var providers = (state && state.providers) || [];
1380
1473
  var configured = providers.some(function (p) { return p.enabled && p.apiKeyHint; });
1381
1474
  var running = (state && state.tasks && state.tasks.running) || [];
1475
+ var attention = (state && state.tasks && state.tasks.attention) || [];
1382
1476
 
1383
1477
  var posPair = React.useState(function () {
1384
1478
  var saved = null;
@@ -1464,8 +1558,9 @@ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) =>
1464
1558
  style = { right: DEFAULT_X + 'px', bottom: DEFAULT_Y + 'px' };
1465
1559
  }
1466
1560
 
1467
- var badge = running.length
1468
- ? React.createElement('span', { className: 'iris-bubble-badge' }, running.length)
1561
+ var badgeCount = running.length + attention.length;
1562
+ var badge = badgeCount
1563
+ ? React.createElement('span', { className: 'iris-bubble-badge', title: attention.length ? attention.length + ' 个任务需要处理' : '' }, badgeCount)
1469
1564
  : null;
1470
1565
 
1471
1566
  var panel = null;
package/lib/doctor.js ADDED
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { attentionDisposition, deriveUserState, semanticViolations } from './task-semantics.js';
8
+
9
+ const PACKAGE_FILE = new URL('../package.json', import.meta.url);
10
+
11
+ function readPackageVersion() {
12
+ try { return JSON.parse(fs.readFileSync(PACKAGE_FILE, 'utf8')).version || 'unknown'; } catch (_) { return 'unknown'; }
13
+ }
14
+
15
+ function result(id, status, summary, suggestion) {
16
+ return { id, status, summary, ...(suggestion ? { suggestion } : {}) };
17
+ }
18
+
19
+ function parseVersion(value) {
20
+ const match = String(value || '').match(/^(\d+)\.(\d+)\.(\d+)/);
21
+ return match ? match.slice(1).map(Number) : [0, 0, 0];
22
+ }
23
+
24
+ function atLeast(actual, required) {
25
+ for (let i = 0; i < 3; i++) {
26
+ if (actual[i] !== required[i]) return actual[i] > required[i];
27
+ }
28
+ return true;
29
+ }
30
+
31
+ function readJson(file) {
32
+ if (!fs.existsSync(file)) return { exists: false, value: null };
33
+ try { return { exists: true, value: JSON.parse(fs.readFileSync(file, 'utf8')) }; }
34
+ catch (_) { return { exists: true, value: null, error: 'JSON 无法解析' }; }
35
+ }
36
+
37
+ function plain(value) {
38
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
39
+ }
40
+
41
+ function isGroupOrWorldAccessible(target) {
42
+ if (process.platform === 'win32' || !fs.existsSync(target)) return false;
43
+ try { return (fs.lstatSync(target).mode & 0o077) !== 0; } catch (_) { return false; }
44
+ }
45
+
46
+ function privateFileModeProblems(dataDir) {
47
+ if (process.platform === 'win32') return [];
48
+ return ['providers.json', 'tasks.json', 'prompt-optimizer.json']
49
+ .filter((name) => isGroupOrWorldAccessible(path.join(dataDir, name)));
50
+ }
51
+
52
+ function commandAvailable(command, runner) {
53
+ try {
54
+ const run = runner(command, ['-version']);
55
+ return Boolean(run && run.status === 0 && !run.error);
56
+ } catch (_) { return false; }
57
+ }
58
+
59
+ function nearestExisting(target) {
60
+ let current = target;
61
+ for (;;) {
62
+ if (fs.existsSync(current)) return current;
63
+ const parent = path.dirname(current);
64
+ if (parent === current) return null;
65
+ current = parent;
66
+ }
67
+ }
68
+
69
+ function writableProbe(dataDir) {
70
+ const base = fs.existsSync(dataDir) ? dataDir : nearestExisting(path.dirname(dataDir));
71
+ if (!base) return false;
72
+ const probe = path.join(base, `.iris-doctor-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
73
+ try {
74
+ fs.writeFileSync(probe, 'probe', { flag: 'wx', mode: 0o600 });
75
+ fs.rmSync(probe);
76
+ return true;
77
+ } catch (_) {
78
+ try { fs.rmSync(probe, { force: true }); } catch (_) {}
79
+ return false;
80
+ }
81
+ }
82
+
83
+ function inspectConfig(dataDir, checks) {
84
+ const config = readJson(path.join(dataDir, 'providers.json'));
85
+ if (!config.exists) {
86
+ checks.push(result('config', 'warn', '尚未创建供应商配置', '启动 Iris 后在工作台添加供应商,或准备好 DSH_HOME 后再运行 doctor。'));
87
+ return { providers: [], assignments: {} };
88
+ }
89
+ if (config.error || !plain(config.value) || !Array.isArray(config.value.providers)
90
+ || !plain(config.value.assignments || {})) {
91
+ checks.push(result('config', 'error', 'providers.json 结构无效或无法解析', '先备份该文件,再从 Iris 工作台重新配置;不要把密钥粘贴到诊断输出。'));
92
+ return { providers: [], assignments: {} };
93
+ }
94
+ const providers = config.value.providers;
95
+ let incomplete = 0;
96
+ let enabled = 0;
97
+ let models = 0;
98
+ for (const provider of providers) {
99
+ if (!plain(provider)) { incomplete++; continue; }
100
+ if (provider.enabled !== false) enabled++;
101
+ if (provider.enabled !== false && (!String(provider.baseUrl || '').trim() || !String(provider.apiKey || '').trim())) incomplete++;
102
+ models += Array.isArray(provider.models) ? provider.models.length : 0;
103
+ }
104
+ if (incomplete) checks.push(result('providers', 'warn', `${incomplete} 个启用供应商缺少 Base URL、API Key 或结构无效`, '在 Iris 工作台补全或停用对应供应商。'));
105
+ else if (!enabled) checks.push(result('providers', 'warn', '没有启用的供应商', '在 Iris 工作台启用至少一个供应商。'));
106
+ else checks.push(result('providers', 'ok', `${enabled} 个供应商已启用;凭据未输出`));
107
+ const assignments = config.value.assignments || {};
108
+ const assignmentCount = Object.keys(assignments).length;
109
+ if (!models) checks.push(result('models', 'warn', '模型池为空或尚未发现模型', '在工作台对供应商执行“发现模型”,再核对能力标签。'));
110
+ else checks.push(result('models', 'ok', `模型池记录 ${models} 个模型,能力分配 ${assignmentCount} 项`));
111
+ return { providers, assignments };
112
+ }
113
+
114
+ function inspectTasks(dataDir, checks) {
115
+ const stored = readJson(path.join(dataDir, 'tasks.json'));
116
+ if (!stored.exists) {
117
+ checks.push(result('tasks', 'ok', '尚无任务记录'));
118
+ return [];
119
+ }
120
+ if (stored.error || !plain(stored.value) || !Array.isArray(stored.value.tasks)) {
121
+ checks.push(result('tasks', 'error', 'tasks.json 结构无效或无法解析', '保留损坏文件作为证据;重新启动 Iris 会尝试隔离并建立新任务表。'));
122
+ return [];
123
+ }
124
+ let invalid = 0;
125
+ let attention = 0;
126
+ for (const task of stored.value.tasks) {
127
+ if (!plain(task) || !String(task.id || '').trim()) { invalid++; continue; }
128
+ if (task.schemaVersion === 2) {
129
+ if (semanticViolations(task).length) invalid++;
130
+ const userState = deriveUserState(task);
131
+ if (['watching_paused', 'needs_attention', 'artifact_unavailable'].includes(userState)
132
+ && attentionDisposition(task).status !== 'acknowledged') attention++;
133
+ } else if (task.status === 'running' && !task.remoteTaskId) attention++;
134
+ }
135
+ if (invalid) checks.push(result('tasks', 'error', `${invalid} 条任务记录语义或结构无效`, '不要手工改写任务事实;先备份 tasks.json 并保留损坏证据。'));
136
+ else if (attention) checks.push(result('tasks', 'warn', `${stored.value.tasks.length} 条任务中有 ${attention} 条需要人工确认`, '在 Iris 工作台的“需要处理”分区查看,并选择重新观察、重新交付、标为已读或知情重试。'));
137
+ else checks.push(result('tasks', 'ok', `${stored.value.tasks.length} 条任务记录结构有效`));
138
+ return stored.value.tasks.filter(plain);
139
+ }
140
+
141
+ function inspectArtifacts(dataDir, tasks, checks) {
142
+ const outputs = path.join(dataDir, 'outputs');
143
+ const referenced = new Set();
144
+ let missing = 0;
145
+ for (const task of tasks) {
146
+ for (const name of task.files || []) {
147
+ const safe = path.basename(String(name));
148
+ referenced.add(safe);
149
+ try { if (!fs.statSync(path.join(outputs, safe)).isFile()) missing++; } catch (_) { missing++; }
150
+ }
151
+ for (const item of task.media || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
152
+ for (const item of task.attachments || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
153
+ }
154
+ let orphan = 0;
155
+ if (fs.existsSync(outputs)) {
156
+ for (const name of fs.readdirSync(outputs)) {
157
+ const target = path.join(outputs, name);
158
+ try { if (fs.lstatSync(target).isFile() && !referenced.has(name)) orphan++; } catch (_) {}
159
+ }
160
+ }
161
+ if (missing) checks.push(result('artifacts', 'warn', `${missing} 个任务产物不可访问;另有 ${orphan} 个孤儿文件`, '在工作台检查任务详情并运行孤儿产物扫描。'));
162
+ else if (orphan) checks.push(result('artifacts', 'warn', `${orphan} 个产物文件没有任务引用`, '先在工作台运行孤儿扫描,确认后再删除。'));
163
+ else checks.push(result('artifacts', 'ok', `${referenced.size} 个已引用产物可核对,未发现孤儿文件`));
164
+
165
+ const uploads = path.join(dataDir, 'uploads');
166
+ let temporary = 0;
167
+ if (fs.existsSync(uploads)) {
168
+ for (const name of fs.readdirSync(uploads)) if (name.endsWith('.part')) temporary++;
169
+ }
170
+ checks.push(temporary
171
+ ? result('temporary-files', 'warn', `${temporary} 个上传临时 .part 文件残留`, '确认没有上传进行中后重启 Iris,由启动清理逻辑处理。')
172
+ : result('temporary-files', 'ok', '未发现上传临时文件残留'));
173
+ }
174
+
175
+ /**
176
+ * 零网络离线诊断。仅创建并立即删除一个写入探针;不启动 DSH、不读取或输出 API Key。
177
+ */
178
+ export async function doctor(options = {}) {
179
+ const dshHome = path.resolve(options.dshHome || process.env.DSH_HOME || path.join(os.homedir(), '.dsh'));
180
+ const dataDir = path.join(dshHome, 'iris', 'v1');
181
+ const runner = options.commandRunner || ((command, args) => spawnSync(command, args, { stdio: 'ignore', shell: false }));
182
+ const sharpLoader = options.sharpLoader || (() => import('sharp'));
183
+ const nodeVersion = options.nodeVersion || process.versions.node;
184
+ const checks = [];
185
+
186
+ checks.push(atLeast(parseVersion(nodeVersion), [20, 10, 0])
187
+ ? result('node', 'ok', `Node.js ${nodeVersion}`)
188
+ : result('node', 'error', `Node.js ${nodeVersion} 低于最低要求 20.10.0`, '升级 Node.js 后再运行 Iris。'));
189
+ try { await sharpLoader(); checks.push(result('sharp', 'ok', 'sharp 可加载')); }
190
+ catch (_) { checks.push(result('sharp', 'error', 'sharp 无法加载', '在当前平台重新安装 @mokuyoaxis/dsh-iris,确认 sharp 有可用的 ARM64/系统构建。')); }
191
+ for (const command of ['ffmpeg', 'ffprobe']) {
192
+ checks.push(commandAvailable(command, runner)
193
+ ? result(command, 'ok', `${command} 可用`)
194
+ : result(command, 'warn', `${command} 不可用`, '视频抽帧与摘要需要 ffmpeg/ffprobe;其他 Iris 能力不受影响。'));
195
+ }
196
+
197
+ const dataExists = fs.existsSync(dataDir);
198
+ if (dataExists) {
199
+ let symlink = false;
200
+ try { symlink = fs.lstatSync(dataDir).isSymbolicLink(); } catch (_) {}
201
+ if (symlink) checks.push(result('storage', 'error', 'Iris 数据目录是符号链接,私有边界无法保证', '改用真实的私有目录并人工迁移数据。'));
202
+ else if (!writableProbe(dataDir)) checks.push(result('storage', 'error', 'Iris 数据目录不可写', '修复当前用户对 $DSH_HOME/iris/v1 的写权限。'));
203
+ else if (isGroupOrWorldAccessible(dataDir)) checks.push(result('storage', 'warn', 'Iris 数据目录允许组或其他用户访问', '在 POSIX 上将目录权限收紧为 0700、文件收紧为 0600。'));
204
+ else {
205
+ const exposedFiles = privateFileModeProblems(dataDir);
206
+ checks.push(exposedFiles.length
207
+ ? result('storage', 'warn', `${exposedFiles.length} 个私有数据文件允许组或其他用户访问`, '在 POSIX 上将 providers.json、tasks.json 与提示词配置权限收紧为 0600。')
208
+ : result('storage', 'ok', 'Iris 数据目录可写、私有文件权限合格,临时探针已清理'));
209
+ }
210
+ } else {
211
+ checks.push(writableProbe(dataDir)
212
+ ? result('storage', 'ok', 'Iris 数据目录尚未创建;父目录可写,临时探针已清理')
213
+ : result('storage', 'error', 'Iris 数据目录不存在且父目录不可写', '设置一个当前用户可写的 DSH_HOME。'));
214
+ }
215
+
216
+ inspectConfig(dataDir, checks);
217
+ const taskRecords = inspectTasks(dataDir, checks);
218
+ inspectArtifacts(dataDir, taskRecords, checks);
219
+ checks.push(result('scope', 'ok', '离线模式未启动 DSH、未检查浏览器/工具注册,也未发送供应商请求'));
220
+
221
+ const errors = checks.filter((item) => item.status === 'error').length;
222
+ const warnings = checks.filter((item) => item.status === 'warn').length;
223
+ return {
224
+ schemaVersion: 1,
225
+ package: { name: '@mokuyoaxis/dsh-iris', version: options.packageVersion || readPackageVersion() },
226
+ mode: 'offline',
227
+ environment: { node: nodeVersion, platform: process.platform, arch: process.arch },
228
+ checks,
229
+ summary: { ok: checks.length - errors - warnings, warnings, errors },
230
+ exitCode: errors ? 2 : (warnings ? 1 : 0)
231
+ };
232
+ }
233
+
234
+ export function formatDoctorReport(report) {
235
+ const mark = { ok: '✓', warn: '!', error: '✗' };
236
+ const lines = [`Iris Doctor ${report.package.version} · ${report.mode}`];
237
+ for (const check of report.checks) {
238
+ lines.push(`${mark[check.status] || '-'} ${check.id}: ${check.summary}`);
239
+ if (check.suggestion) lines.push(` 建议:${check.suggestion}`);
240
+ }
241
+ lines.push(`结果:${report.summary.errors} 错误,${report.summary.warnings} 警告`);
242
+ return lines.join('\n');
243
+ }