dsh-knj-workflow 0.1.94 → 0.1.112

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/client.js CHANGED
@@ -562,12 +562,15 @@ window.__ModuleLoader__.load({
562
562
  this.scheduleCommit(); // 逐键输入防抖合并
563
563
  const wf = this.state.wf;
564
564
  if (wf.nodes.some((n) => n.id === newId && n.id !== oldId)) { toast('阶段编码已存在'); return; }
565
+ // 改 id 后同步所有引用:edges from/to、其他节点的 routes[].to / approveTo / rejectTo / displayFrom / inputs[].from
565
566
  const nodes = wf.nodes.map((n) => {
566
- if (n.id !== oldId) return n;
567
- const u = { ...n, id: newId };
567
+ if (n.id === oldId) return { ...n, id: newId };
568
+ const u = { ...n };
568
569
  if (n.approveTo === oldId) u.approveTo = newId;
569
570
  if (n.rejectTo === oldId) u.rejectTo = newId;
570
571
  if (n.displayFrom === oldId) u.displayFrom = newId;
572
+ if (Array.isArray(n.routes)) u.routes = n.routes.map((r) => (r.to === oldId ? { ...r, to: newId } : r));
573
+ if (Array.isArray(n.inputs)) u.inputs = n.inputs.map((r) => (r.from === oldId ? { ...r, from: newId } : r));
571
574
  return u;
572
575
  });
573
576
  const edges = wf.edges.map((e) => ({ ...e, from: e.from === oldId ? newId : e.from, to: e.to === oldId ? newId : e.to }));
@@ -581,8 +584,8 @@ window.__ModuleLoader__.load({
581
584
  if (type === 'task') {
582
585
  Object.assign(base, { inputs: [], body: { prompt: '', mode: 'single', output: {} }, prehook: [], posthook: [] });
583
586
  } else if (type === 'human') {
584
- // 人工节点不预设去向:通常在流程中间(如设计评审),通过/驳回去向由用户按需配置
585
- Object.assign(base, { displayFrom: '', approveTo: '', rejectTo: '' });
587
+ // 人工节点不预设去向:通常在流程中间(如设计评审),去向由用户按需配置
588
+ Object.assign(base, { displayFrom: '', routes: [] });
586
589
  }
587
590
  wf.nodes.push(base);
588
591
  this.pushNow(wf); // 新增节点后提交,撤销可移除
@@ -603,23 +606,61 @@ window.__ModuleLoader__.load({
603
606
  schemaVersion: 2, inputs: wf.inputs || [],
604
607
  nodes: wf.nodes.map((n) => {
605
608
  const out = { ...n, inputs: (n.inputs || []).filter((r) => r.from && r.field) };
606
- // 人工节点统一输出 routes(旧 approveTo/rejectTo 自动迁移为 通过/驳回 两条去向)
607
- if (n.type === 'human' && !Array.isArray(n.routes)) {
608
- const routes = [];
609
- if (n.approveTo) routes.push({ label: '通过', to: n.approveTo, tone: 'success' });
610
- if (n.rejectTo) routes.push({ label: '驳回', to: n.rejectTo, tone: 'danger' });
611
- out.routes = routes;
609
+ // 人工节点统一输出 routes(旧 approveTo/rejectTo 自动迁移为 通过/驳回 两条去向),并清掉旧字段
610
+ if (n.type === 'human') {
611
+ if (!Array.isArray(n.routes)) {
612
+ const routes = [];
613
+ if (n.approveTo) routes.push({ label: '通过', to: n.approveTo, tone: 'success' });
614
+ if (n.rejectTo) routes.push({ label: '驳回', to: n.rejectTo, tone: 'danger' });
615
+ out.routes = routes;
616
+ }
617
+ delete out.approveTo;
618
+ delete out.rejectTo;
612
619
  }
613
620
  return out;
614
621
  }),
615
- edges: wf.edges.map((e) => {
616
- // op=in 时 value 从逗号分隔字符串转成数组;空 when 去掉 field/op/value
617
- if (e.when && e.when.op === 'in' && typeof e.when.value === 'string') {
618
- return { ...e, when: { ...e.when, value: e.when.value.split(',').map((s) => s.trim()).filter(Boolean) } };
622
+ // 边规范化:value 智能转换('true'→true、'5'→5;${} 引用保留字符串)、
623
+ // op=in 时 value 转数组、空 when null、同 from→to 去重合并(自愈历史脏数据)
624
+ edges: (() => {
625
+ // UI 值输入框存的是字符串,而 subagent 输出的布尔/数字是真值——
626
+ // 不转换则 'true' === true 永远失配、条件静默走 default。
627
+ const coerce = (v) => {
628
+ if (typeof v !== 'string') return v;
629
+ if (v.startsWith('${')) return v; // 引用字符串(运行时解析),保留
630
+ if (v === 'true') return true;
631
+ if (v === 'false') return false;
632
+ if (v !== '' && !isNaN(Number(v))) return Number(v);
633
+ return v;
634
+ };
635
+ const norm = wf.edges.map((e) => {
636
+ if (e.when && e.when.op === 'in' && typeof e.when.value === 'string') {
637
+ return { ...e, when: { ...e.when, value: e.when.value.split(',').map((s) => s.trim()).filter(Boolean).map(coerce) } };
638
+ }
639
+ if (e.when && typeof e.when.value === 'string') {
640
+ return { ...e, when: { ...e.when, value: coerce(e.when.value) } };
641
+ }
642
+ if (e.when && !e.when.field) return { ...e, when: null };
643
+ return e;
644
+ });
645
+ const out = [];
646
+ const idxByPair = new Map();
647
+ for (const e of norm) {
648
+ const k = e.from + '->' + e.to;
649
+ const i = idxByPair.get(k);
650
+ if (i === undefined) { idxByPair.set(k, out.length); out.push(e); continue; }
651
+ const prev = out[i];
652
+ // 两条都有条件时不自动合并(静默丢条件会悄悄改变路由语义):
653
+ // 两条都保留,让服务端 validateWorkflow 报「重复边」给用户明确提示。
654
+ if (prev.when && e.when) { out.push(e); continue; }
655
+ // 一条有条件一条没有(如历史脏数据 when 边 + 纯 default 边)→ 合并:when 取有值者,default 任一真则保留
656
+ out[i] = {
657
+ ...prev,
658
+ when: prev.when || e.when || null,
659
+ default: !!(prev.default || e.default),
660
+ };
619
661
  }
620
- if (e.when && !e.when.field) return { ...e, when: null };
621
- return e;
622
- }),
662
+ return out;
663
+ })(),
623
664
  };
624
665
  }
625
666
  async save() {
@@ -630,9 +671,10 @@ window.__ModuleLoader__.load({
630
671
  try {
631
672
  await api('/workflows', { method: 'POST', body: graph });
632
673
  toast('已保存');
633
- // 保存成功 = 新基线:清空 undo/redo,防止撤销跨保存边界
674
+ // 保存成功 = 新基线:规范化结果(去重边/迁移 routes)回写 state,UI 立即干净
634
675
  this.flushPending();
635
- this.history = [JSON.parse(JSON.stringify(this.state.wf))];
676
+ this.setState({ wf: graph });
677
+ this.history = [JSON.parse(JSON.stringify(graph))];
636
678
  this.redoStack = [];
637
679
  this.setState({ canUndo: false, canRedo: false });
638
680
  this.props.onSaved();
@@ -765,7 +807,8 @@ window.__ModuleLoader__.load({
765
807
  ]),
766
808
  sec('行为 prompt(task 节点)', [
767
809
  '本节点让 AI 做什么,写清目标与约束。',
768
- '可引用:${inputDescription}(任务需求描述)、${inputTitle}(标题)、${inputs.参数名}(任务输入)。',
810
+ '可引用:${inputDescription}(任务需求描述)、${inputTitle}(标题)、${storyCode}(用户故事编码)、${cwd}(任务工作目录)、${inputs.参数名}(任务输入)。',
811
+ '运行时每个 task 节点会自动声明任务工作目录(所有文件/命令操作都在其中)。',
769
812
  '需要 skill 时在 prompt 里直接写「先调用 skill 工具加载 skill「xxx」」。',
770
813
  '上游输出引用(${上游编码.字段}):',
771
814
  ...this.buildRefsText(this.state.wf.nodes.find((n) => n.id === this.state.selectedId)).split('\n').map((s) => ' ' + s),
@@ -1046,7 +1089,7 @@ window.__ModuleLoader__.load({
1046
1089
  }
1047
1090
  renderEdgeProps() {
1048
1091
  const key = this.state.selectedEdge;
1049
- // 人工决策边(通过/驳回):key 形如 human:approve:<nodeId> / human:reject:<nodeId>
1092
+ // 人工决策边(多去向):key 形如 human:<nodeId>:<index>
1050
1093
  if (key.startsWith('human:')) return this.renderHumanEdgeProps(key);
1051
1094
  const [from, to] = key.split('->');
1052
1095
  const edge = this.state.wf.edges.find((e) => e.from === from && e.to === to);
@@ -1058,15 +1101,17 @@ window.__ModuleLoader__.load({
1058
1101
  react.createElement('div', { className: 'knj-form-label' }, '连线'),
1059
1102
  react.createElement('div', { style: { fontSize: 13 } }, `${from} → ${to}`),
1060
1103
  needCondition ? react.createElement('div', null,
1061
- react.createElement('div', { className: 'knj-form-label', style: { marginTop: 10 } }, '条件(when)'),
1062
- react.createElement('input', { className: 'knj-input', placeholder: '字段名(相对上游输出,如 complexity)', value: edge.when?.field || '', onChange: (e) => this.patchEdge(key, { when: { op: 'eq', value: '', ...edge.when, field: e.target.value } }) }),
1104
+ react.createElement('div', { className: 'knj-form-label', style: { marginTop: 10 } }, '条件(when)',
1105
+ react.createElement('span', { onMouseEnter: (e) => this.showTip(e, '字段三种写法:\n· 裸字段名(如 level)——相对作用域:网关取唯一上游输出、任务多出边取自身输出\n· ${节点id.字段}(如 ${verify.passed})——显式引用任意已执行节点,取原始值(布尔/数字直接比较)\n· 裸点号路径(如 verify.passed)——同上\n\n值:布尔/数字自动识别(true/false/5 存为真值);支持 ${inputs.参数名} 引用;in 用逗号分隔。'), onMouseLeave: () => this.hideTip(), style: { cursor: 'help', color: 'var(--dsw-alias-label-tertiary)', marginLeft: 6, fontSize: 12 } }, 'ⓘ'),
1106
+ ),
1107
+ react.createElement('input', { className: 'knj-input', placeholder: '字段名(如 level;判断任意上游写 ${节点id.字段})', value: edge.when?.field || '', onChange: (e) => this.patchEdge(key, { when: { op: 'eq', value: '', ...edge.when, field: e.target.value } }) }),
1063
1108
  react.createElement('div', { className: 'knj-row', style: { marginTop: 8 } },
1064
1109
  react.createElement('select', { className: 'knj-input', style: { maxWidth: 120 }, value: edge.when?.op || 'eq', onChange: (e) => this.patchEdge(key, { when: { field: '', ...edge.when, op: e.target.value } }) },
1065
1110
  react.createElement('option', { value: 'eq' }, '等于'),
1066
1111
  react.createElement('option', { value: 'neq' }, '不等于'),
1067
1112
  react.createElement('option', { value: 'in' }, '属于'),
1068
1113
  ),
1069
- react.createElement('input', { className: 'knj-input', placeholder: '值(in 用逗号分隔,如 high,low)', value: this.valueToText(edge.when?.value), onChange: (e) => this.patchEdge(key, { when: { ...edge.when, value: e.target.value } }) }),
1114
+ react.createElement('input', { className: 'knj-input', placeholder: '值(布尔/数字自动识别;in 用逗号分隔;支持 ${inputs.x})', value: this.valueToText(edge.when?.value), onChange: (e) => this.patchEdge(key, { when: { ...edge.when, value: e.target.value } }) }),
1070
1115
  ),
1071
1116
  edge.when ? react.createElement('button', { className: 'knj-btn', style: { alignSelf: 'flex-start', marginTop: 6 }, onClick: () => this.patchEdge(key, { when: null }) }, '清除条件') : null,
1072
1117
  ) : react.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', marginTop: 8 } }, '顺序边,无需条件判断'),
@@ -1261,7 +1306,7 @@ window.__ModuleLoader__.load({
1261
1306
  fieldLabel('节点编码(id)', '节点唯一标识:下游用 ${编码.字段} 引用(如 ${analyze.description}),网关用字段值做分支判断。修改后 prompt 里旧的 ${旧编码.字段} 引用需手动更新。', '下游用 ${编码.字段} 引用,如 ${analyze.description}'),
1262
1307
  react.createElement('input', { className: 'knj-input', style: { marginBottom: 8 }, placeholder: '如:analyze', defaultValue: node.id || '', onBlur: (e) => this.patchNodeId(node.id, e.target.value.trim()) }),
1263
1308
  isTask ? react.createElement('div', null,
1264
- fieldLabel('行为 prompt(主体)', '本节点让 AI 做什么,写清目标与约束。\n可引用:\n${inputDescription}(任务需求描述)\n${inputTitle}(标题)\n${inputs.参数名}(任务输入)\n\n上游输出引用(${上游编码.字段}):\n' + this.buildRefsText(node) + '\n\n注意:XOR 分支未执行的节点引用为空;整节点输出用 ${节点id};${ctx.前缀写法仍兼容。', '让 AI 做什么;可引用 ${inputDescription}、${上游编码.字段}(ⓘ 看完整清单)'),
1309
+ fieldLabel('行为 prompt(主体)', '本节点让 AI 做什么,写清目标与约束。\n可引用:\n${inputDescription}(任务需求描述)\n${inputTitle}(标题)\n${storyCode}(用户故事编码,可选)\n${cwd}(任务工作目录;运行时还会自动向 subagent 声明该目录)\n${inputs.参数名}(任务输入)\n\n上游输出引用(${上游编码.字段}):\n' + this.buildRefsText(node) + '\n\n注意:XOR 分支未执行的节点引用为空;整节点输出用 ${节点id};${ctx.前缀写法仍兼容。', '让 AI 做什么;可引用 ${inputDescription}、${storyCode}、${cwd}、${上游编码.字段}(ⓘ 看完整清单)'),
1265
1310
  react.createElement('textarea', { className: 'knj-textarea', style: { minHeight: 140 }, placeholder: '如:根据需求 ${inputDescription} 输出功能描述与验收标准', value: node.body?.prompt || '', onChange: (e) => this.patchNode(node.id, { body: { ...node.body, prompt: e.target.value } }) }),
1266
1311
  fieldLabel('执行方式', 'single:一个 AI 完成本阶段,输出单个对象。\nparallel:派多个 AI 并行做同一件事,结果合并为数组(配合「最大执行次数」外的 parallelItems 使用,见输出说明)。', 'single:单个 AI 完成;parallel:多个 AI 并行,结果合并为数组'),
1267
1312
  react.createElement('select', { className: 'knj-input', value: node.body?.mode || 'single', onChange: (e) => this.patchNode(node.id, { body: { ...node.body, mode: e.target.value } }) },
@@ -1435,26 +1480,35 @@ window.__ModuleLoader__.load({
1435
1480
  if (typeof s !== 'string' || !s.trim()) return false;
1436
1481
  const v = s.trim();
1437
1482
  if (v.length > 220) return false;
1438
- return /\.\w{1,12}$/.test(v) || /[/\\]/.test(v);
1483
+ if (v.includes(' ')) return false; // 路径不含空格:错误消息(含空格+中文)不会被误判
1484
+ // 绝对/相对路径开头(/ ./ ../ 盘符:\ ~/),或纯文件名带扩展名
1485
+ return /^(\/|\.{1,2}\/|[A-Za-z]:[\\/]|~\/)/.test(v) || /^\S+\.\w{1,12}$/.test(v);
1439
1486
  }
1440
1487
  /** 从产物(JSON)里收集看起来像文件路径的字符串(去重,限制数量) */
1488
+ /** 从产物(JSON)里收集文件路径:只认「文件类字段」的数组元素(changedFiles/affectedFiles/paths 等),
1489
+ * 错误消息/描述等文本字段不收集(避免把错误文案里的路径误判成文件)。去重,限制数量。 */
1441
1490
  function collectFiles(value, out, depth) {
1442
1491
  out = out || new Set();
1443
1492
  depth = depth || 0;
1444
1493
  if (depth > 4 || out.size >= 20) return out;
1445
- if (value === null || value === undefined) return out;
1446
- if (typeof value !== 'object') {
1447
- if (typeof value === 'string' && looksLikeFile(value)) out.add(value.trim());
1494
+ if (value === null || value === undefined || typeof value !== 'object') return out;
1495
+ if (Array.isArray(value)) {
1496
+ // 仅当整体是文件列表数组时(由上层文件字段进入)才收元素;普通数组不递归收字符串
1497
+ for (const v of value) {
1498
+ if (typeof v === 'string') { if (looksLikeFile(v)) out.add(v.trim()); }
1499
+ else collectFiles(v, out, depth + 1);
1500
+ }
1448
1501
  return out;
1449
1502
  }
1450
- if (Array.isArray(value)) {
1451
- for (const v of value) collectFiles(v, out, depth + 1);
1452
- } else {
1453
- for (const k of Object.keys(value)) {
1454
- // 字段名暗示文件列表(changedFiles/affectedFiles/files 等)时,集合其数组元素
1455
- if (Array.isArray(value[k]) && /(file|path|src|output|artifact)/i.test(k)) {
1456
- for (const v of value[k]) collectFiles(v, out, depth + 1);
1457
- } else collectFiles(value[k], out, depth + 1);
1503
+ for (const k of Object.keys(value)) {
1504
+ // 字段名暗示文件列表(changedFiles/affectedFiles/files/paths/artifacts 等)→ 收集其数组元素
1505
+ if (Array.isArray(value[k]) && /(file|path|src|output|artifact)/i.test(k)) {
1506
+ for (const v of value[k]) {
1507
+ if (typeof v === 'string') { if (looksLikeFile(v)) out.add(v.trim()); }
1508
+ else collectFiles(v, out, depth + 1);
1509
+ }
1510
+ } else {
1511
+ collectFiles(value[k], out, depth + 1);
1458
1512
  }
1459
1513
  }
1460
1514
  return out;
@@ -1471,53 +1525,90 @@ window.__ModuleLoader__.load({
1471
1525
  // 新建任务模态(完整表单)+ 中央工作台(子 tab)+ 打开函数
1472
1526
  // ---------------------------------------------------------------------------
1473
1527
  let newTaskRoot = null;
1528
+ let newTaskRootHandle = null;
1474
1529
  function openNewTaskModal() {
1475
1530
  ensureStyle();
1476
1531
  if (newTaskRoot) { newTaskRoot.style.display = 'flex'; return; }
1477
1532
  newTaskRoot = document.createElement('div');
1478
1533
  document.body.appendChild(newTaskRoot);
1479
- react_dom_client.createRoot(newTaskRoot).render(react.createElement(NewTaskModal, {
1480
- onClose: () => { if (newTaskRoot) newTaskRoot.style.display = 'none'; },
1481
- }));
1534
+ // 关闭 = 真正卸载:下次打开表单全新(不残留上次填的标题/描述/工作目录/参数)
1535
+ const close = () => {
1536
+ if (newTaskRootHandle) { newTaskRootHandle.unmount(); newTaskRootHandle = null; }
1537
+ if (newTaskRoot) { newTaskRoot.remove(); newTaskRoot = null; }
1538
+ };
1539
+ newTaskRootHandle = react_dom_client.createRoot(newTaskRoot);
1540
+ newTaskRootHandle.render(react.createElement(NewTaskModal, { onClose: close }));
1482
1541
  }
1483
1542
 
1484
1543
  class NewTaskModal extends react.Component {
1485
1544
  constructor(props) {
1486
1545
  super(props);
1487
- this.state = { description: '', title: '', workflowId: '', cwd: '', cwdOptions: [], inputs: {}, workflows: [], busy: false, error: null };
1546
+ this.state = { description: '', title: '', storyCode: '', workflowId: '', cwd: '', cwdOptions: [], inputs: {}, workflows: [], busy: false, error: null, model: '', provider: '', models: [] };
1488
1547
  }
1489
1548
  componentDidMount() { this.load(); }
1549
+ /** 取当前会话的模型/provider 作为表单默认值(用户可改成其他模型,如 glm 额度用完时切 deepseek) */
1550
+ currentModelDefaults() {
1551
+ try {
1552
+ const ag = _ctx?.get?.('agents');
1553
+ const init = ag?.currentInitiator?.() ?? ag?.roots?.()?.[0];
1554
+ const opts = init?.options || {};
1555
+ return { model: opts.model || '', provider: opts.provider || '' };
1556
+ } catch { return { model: '', provider: '' }; }
1557
+ }
1558
+ /** 拉取可用模型列表:直接走 Host 插件 /models(本地解析配置文件,毫秒级)。
1559
+ * 不再尝试 connection.api.llm.models RPC——它会遍历所有 provider 并逐个远端 resolve,
1560
+ * 在 key 失效/网络慢时挂起很久,导致表单"加载半天才出来"。 */
1561
+ async loadModels() {
1562
+ const list = [];
1563
+ try {
1564
+ const r = await api('/models');
1565
+ for (const m of (r && r.models) || []) list.push(m);
1566
+ } catch { /* 失败则留空(仅显示"继承当前会话") */ }
1567
+ const seen = new Set();
1568
+ const uniq = list.filter((m) => {
1569
+ const k = (m.provider || '') + '::' + (m.model || '');
1570
+ if (!m.model || seen.has(k)) return false;
1571
+ seen.add(k);
1572
+ return true;
1573
+ });
1574
+ if (uniq.length === 0) console.warn('[knj-workflow] 模型列表为空:/models 未返回模型');
1575
+ this.setState({ models: uniq });
1576
+ }
1577
+ /** 工作目录下拉:异步加载,不阻塞表单渲染(refresh 可能慢/挂起,最多等 3 秒) */
1578
+ async loadCwdOptions() {
1579
+ try {
1580
+ const ws = _ctx?.get?.('workspaces') || _workspaces;
1581
+ if (ws && typeof ws.refresh === 'function' && !(ws.list?.getSnapshot?.()?.items || []).length) {
1582
+ await Promise.race([
1583
+ ws.refresh(),
1584
+ new Promise((resolve) => setTimeout(resolve, 3000)),
1585
+ ]);
1586
+ }
1587
+ const items = ws?.list?.getSnapshot?.()?.items || [];
1588
+ const cwdOptions = [];
1589
+ for (const item of items) {
1590
+ const view = item?.getSnapshot?.()?.view || item || {};
1591
+ const path = view.path || view.cwd || '';
1592
+ const title = view.title || view.name || (path ? path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : '');
1593
+ if (path) cwdOptions.push({ path, title: title || path });
1594
+ }
1595
+ this.setState({ cwdOptions });
1596
+ } catch { /* 加载失败保持空 */ }
1597
+ }
1490
1598
  async load() {
1491
1599
  try {
1492
1600
  const w = await api('/workflows');
1493
1601
  const workflows = w.workflows || [];
1494
- // 工作目录下拉选项:来自 DSH 工作区列表(path 为 canonical 目录,title 为 basename)
1495
- const cwdOptions = [];
1496
- try {
1497
- // 动态取 workspaces 服务(避免 apply 时机缓存失败);items 为空时主动 refresh 拉取
1498
- const ws = _ctx?.get?.('workspaces') || _workspaces;
1499
- if (ws && typeof ws.refresh === 'function' && !(ws.list?.getSnapshot?.()?.items || []).length) {
1500
- await ws.refresh();
1501
- }
1502
- const items = ws?.list?.getSnapshot?.()?.items || [];
1503
- console.log('[knj-workflow] workspaces:', ws ? 'ok' : 'null', 'items:', items.length, 'sample:', items[0] ? (items[0].getSnapshot ? JSON.stringify(items[0].getSnapshot().view) : JSON.stringify(items[0])) : null);
1504
- for (const item of items) {
1505
- // 防御两种结构:Workspace 实例(有 getSnapshot().view)或 raw workspace view(自身即带 path)
1506
- const view = item?.getSnapshot?.()?.view || item || {};
1507
- const path = view.path || view.cwd || '';
1508
- const title = view.title || view.name || (path ? path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : '');
1509
- console.log('[knj-workflow] cwd item:', 'hasGetSnapshot=' + (typeof item?.getSnapshot === 'function'), 'path=' + path, 'title=' + (title || ''));
1510
- if (path) cwdOptions.push({ path, title: title || path });
1511
- }
1512
- } catch (e) { console.warn('[knj-workflow] 加载工作目录失败:', e); }
1513
- console.log('[knj-workflow] cwdOptions built:', JSON.stringify(cwdOptions));
1514
- this.setState({ workflows, workflowId: workflows[0]?.id || '', cwdOptions, error: null });
1602
+ const def = this.currentModelDefaults();
1603
+ // 先渲染表单主体(工作流/默认模型),工作区与模型列表异步填充,不等慢请求
1604
+ this.setState({ workflows, workflowId: workflows[0]?.id || '', model: def.model, provider: def.provider, error: null });
1605
+ this.loadCwdOptions();
1606
+ this.loadModels();
1515
1607
  } catch (e) { this.setState({ error: e.message }); }
1516
1608
  }
1517
1609
  selectedWorkflow() { return this.state.workflows.find((w) => w.id === this.state.workflowId); }
1518
1610
  render() {
1519
- const { description, title, workflowId, cwd, cwdOptions, inputs, workflows, busy, error } = this.state;
1520
- console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1611
+ const { description, title, storyCode, workflowId, cwd, cwdOptions, inputs, workflows, busy, error, model, provider } = this.state;
1521
1612
  const wf = this.selectedWorkflow();
1522
1613
  const wfInputs = (wf && Array.isArray(wf.inputs)) ? wf.inputs : [];
1523
1614
  return react.createElement('div', { className: 'knj-overlay' },
@@ -1536,6 +1627,10 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1536
1627
  react.createElement('div', { className: 'knj-form-label' }, '标题(创建后自动总结,可改)'),
1537
1628
  react.createElement('input', { className: 'knj-input', placeholder: '留空则创建后自动总结', value: title, onChange: (e) => this.setState({ title: e.target.value }) }),
1538
1629
  ),
1630
+ react.createElement('div', { className: 'knj-form-section' },
1631
+ react.createElement('div', { className: 'knj-form-label', title: '用户故事编号(如 US-123 / STORY-45)。可选;填了之后节点 prompt 里可用 ${storyCode} 引用(如记录到提交信息/文档标题)。' }, '用户故事编码(可选) ⓘ'),
1632
+ react.createElement('input', { className: 'knj-input', placeholder: '如 US-123(可留空)', value: storyCode, onChange: (e) => this.setState({ storyCode: e.target.value }) }),
1633
+ ),
1539
1634
  react.createElement('div', { className: 'knj-form-section' },
1540
1635
  react.createElement('div', { className: 'knj-form-label' }, '工作流'),
1541
1636
  react.createElement('select', { className: 'knj-input', value: workflowId, onChange: (e) => this.setState({ workflowId: e.target.value }) },
@@ -1550,6 +1645,21 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1550
1645
  react.createElement('div', { className: 'knj-form-label' }, '任务输入参数'),
1551
1646
  wfInputs.map((inp) => react.createElement('input', { key: inp.name, className: 'knj-input', style: { marginBottom: 8 }, placeholder: `${inp.label || inp.name}${inp.required ? '(必填)' : ''}`, value: inputs[inp.name] || '', onChange: (e) => this.setState({ inputs: { ...inputs, [inp.name]: e.target.value } }) })),
1552
1647
  ) : null,
1648
+ react.createElement('div', { className: 'knj-form-section' },
1649
+ react.createElement('div', { className: 'knj-form-label', title: '所有阶段 subagent 都用这个模型执行。默认继承你当前会话的模型(见下拉第一项)。当前模型额度不足时,可在这里换成其他可用模型避免任务失败。' }, '执行模型(可选,留空继承当前会话) ⓘ'),
1650
+ react.createElement('select', { className: 'knj-input', value: model ? provider + '::' + model : '', onChange: (e) => {
1651
+ const v = e.target.value;
1652
+ if (!v) { this.setState({ model: '', provider: '' }); return; }
1653
+ const sep = v.indexOf('::');
1654
+ this.setState({ provider: v.slice(0, sep), model: v.slice(sep + 2) });
1655
+ } },
1656
+ // 第一项 = 继承当前会话(显示当前模型名);列表排除与它重复的模型,避免出现两个一样的选项
1657
+ react.createElement('option', { value: '' }, model ? `继承当前会话(${provider}/${model})` : '继承当前会话(默认)'),
1658
+ this.state.models
1659
+ .filter((m) => m.provider + '::' + m.model !== (model ? provider + '::' + model : ''))
1660
+ .map((m) => react.createElement('option', { key: m.provider + '::' + m.model, value: m.provider + '::' + m.model }, `${m.provider}/${m.model}`)),
1661
+ ),
1662
+ ),
1553
1663
  react.createElement('div', { className: 'knj-row', style: { justifyContent: 'flex-end' } },
1554
1664
  react.createElement('button', { className: 'knj-btn', onClick: this.props.onClose }, '取消'),
1555
1665
  react.createElement('button', { className: 'knj-btn primary', disabled: busy || !description.trim() || !workflowId, onClick: () => this.submit() }, '创建并启动'),
@@ -1559,14 +1669,18 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1559
1669
  );
1560
1670
  }
1561
1671
  async submit() {
1562
- const { description, title, workflowId, cwd, inputs } = this.state;
1672
+ const { description, title, storyCode, workflowId, cwd, inputs, model, provider } = this.state;
1563
1673
  const wf = this.selectedWorkflow();
1564
1674
  const wfInputs = (wf && Array.isArray(wf.inputs)) ? wf.inputs : [];
1565
1675
  this.setState({ busy: true });
1566
1676
  try {
1567
1677
  const body = { title: title.trim() || description.trim().slice(0, 50), workflowId };
1568
1678
  body.description = description.trim();
1679
+ if (storyCode.trim()) body.storyCode = storyCode.trim();
1569
1680
  if (cwd.trim()) body.cwd = cwd.trim();
1681
+ // 模型配置:填了就固定用(所有阶段 subagent),空则继承当前会话
1682
+ if (model.trim()) body.model = model.trim();
1683
+ if (provider.trim()) body.provider = provider.trim();
1570
1684
  const filled = {};
1571
1685
  wfInputs.forEach((inp) => { const v = inputs[inp.name]; if (v != null && v !== '') filled[inp.name] = v; });
1572
1686
  if (Object.keys(filled).length) body.inputs = filled;
@@ -1602,14 +1716,21 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1602
1716
  }
1603
1717
 
1604
1718
  let devBoardRoot = null;
1719
+ let devBoardRootHandle = null; // React root 句柄(关闭时需 unmount 清定时器,不能只 remove DOM)
1605
1720
  let _closeDevBoard = null; // 收起开发任务弹窗(跳转子会话前先收起,避免盖住)
1606
1721
  function openDevBoard() {
1607
1722
  ensureStyle();
1608
1723
  if (devBoardRoot) { devBoardRoot.style.display = 'flex'; return; }
1609
1724
  devBoardRoot = document.createElement('div');
1610
1725
  document.body.appendChild(devBoardRoot);
1611
- _closeDevBoard = () => { if (devBoardRoot) devBoardRoot.style.display = 'none'; };
1612
- react_dom_client.createRoot(devBoardRoot).render(react.createElement(DevBoardOverlay, {
1726
+ _closeDevBoard = () => {
1727
+ if (!devBoardRoot && !devBoardRootHandle) return;
1728
+ // 真正卸载而不是 display:none:否则再次打开会残留上次的详情视图/翻页/滚动位置
1729
+ if (devBoardRootHandle) { devBoardRootHandle.unmount(); devBoardRootHandle = null; }
1730
+ if (devBoardRoot) { devBoardRoot.remove(); devBoardRoot = null; }
1731
+ };
1732
+ devBoardRootHandle = react_dom_client.createRoot(devBoardRoot);
1733
+ devBoardRootHandle.render(react.createElement(DevBoardOverlay, {
1613
1734
  onClose: _closeDevBoard,
1614
1735
  }));
1615
1736
  }
@@ -1834,14 +1955,25 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1834
1955
  await this.loadResults();
1835
1956
  if (this.props.onChanged) this.props.onChanged();
1836
1957
  }
1837
- /** 某节点的产物(results.json 里按节点 id 存) */
1958
+ /** 某节点的产物(results.json 里按节点 id 存;展开时若缺失会从 stage 文件补,见 toggle) */
1838
1959
  resultFor(stageId) {
1839
1960
  const results = this.state.results;
1840
1961
  if (!results || typeof results !== 'object') return null;
1841
- return results[stageId];
1842
- }
1843
- toggle(stageId) {
1844
- this.setState((s) => ({ expanded: s.expanded === stageId ? null : stageId }));
1962
+ return results[stageId] ?? null;
1963
+ }
1964
+ async toggle(stageId) {
1965
+ const willExpand = this.state.expanded !== stageId;
1966
+ this.setState((s) => ({ expanded: willExpand ? stageId : null }));
1967
+ // 展开时补产物:results.json 可能为空(任务中断/未 finalize),
1968
+ // 但每节点完成时 checkpoint 会实时落盘 stages/<id>.json——从那里补。
1969
+ if (!willExpand) return;
1970
+ if (this.resultFor(stageId) != null) return;
1971
+ try {
1972
+ const r = await api('/tasks/' + this.props.task.id + '/stages/' + stageId);
1973
+ if (r && r.data != null && typeof r.data === 'object') {
1974
+ this.setState((s) => ({ results: { ...(s.results || {}), [stageId]: r.data } }));
1975
+ }
1976
+ } catch { /* stage 文件也没有则保持无产物 */ }
1845
1977
  }
1846
1978
  duration(s) {
1847
1979
  if (!s.startedAt || !s.finishedAt) return '';
@@ -1860,7 +1992,8 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1860
1992
  const result = this.resultFor(s.id);
1861
1993
  const sessionIds = s.sessionIds || [];
1862
1994
  const expanded = this.state.expanded === s.id;
1863
- const hasDetail = result != null || sessionIds.length > 0 || s.error;
1995
+ // 展开条件放宽:done/failed 节点必有产物(可能在 results 里或 stage 文件里),点了再异步补数据
1996
+ const hasDetail = result != null || sessionIds.length > 0 || s.error || s.status === 'done' || s.status === 'failed';
1864
1997
  const statusIcon = s.status === 'done' ? icon('check')
1865
1998
  : isRunning ? icon('clock')
1866
1999
  : s.status === 'skipped' ? '–'
@@ -1881,9 +2014,9 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1881
2014
  expanded ? react.createElement('div', { className: 'knj-node-body' },
1882
2015
  s.error ? react.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-state-error-primary)', wordBreak: 'break-all' } }, s.error) : null,
1883
2016
  result != null ? react.createElement('div', null,
1884
- react.createElement('div', { className: 'knj-node-sec' }, '产物'),
2017
+ react.createElement('div', { className: 'knj-node-sec' }, '产物(输出字段)'),
2018
+ this.renderResultFields(result),
1885
2019
  this.renderFiles(s.id, result),
1886
- react.createElement('pre', null, JSON.stringify(result, null, 2)),
1887
2020
  ) : null,
1888
2021
  this.state.fileView && this.state.fileView.stageId === s.id ? react.createElement('div', null,
1889
2022
  react.createElement('div', { className: 'knj-node-sec', style: { display: 'flex', alignItems: 'center', gap: 8 } },
@@ -1893,24 +2026,47 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1893
2026
  react.createElement('pre', { style: { maxHeight: 320 } }, this.state.fileView.content),
1894
2027
  ) : null,
1895
2028
  sessionIds.length > 0 ? react.createElement('div', null,
1896
- react.createElement('div', { className: 'knj-node-sec' }, '执行记录'),
2029
+ react.createElement('div', { className: 'knj-node-sec' }, '执行记录(子会话)'),
1897
2030
  sessionIds.map((sid) => react.createElement('div', { key: sid, className: 'knj-row', style: { marginTop: 6, gap: 8 } },
1898
- react.createElement('code', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', flex: 1, wordBreak: 'break-all' } }, sid),
1899
- react.createElement('button', { className: 'knj-btn', style: { flexShrink: 0 }, onClick: () => this.viewSubagent(sid) }, '查看'),
2031
+ react.createElement('code', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', flex: 1, wordBreak: 'break-all' } }, '👤 ' + sid),
2032
+ react.createElement('button', { className: 'knj-btn', style: { flexShrink: 0 }, title: '跳转到该子代理会话查看完整执行细节', onClick: () => this.viewSubagent(sid) }, '查看会话'),
1900
2033
  )),
1901
2034
  ) : null,
1902
2035
  ) : null,
1903
2036
  );
1904
2037
  }
1905
- /** 从产物里识别文件路径并渲染为可点击的「查看」列表 */
2038
+ /** 节点输出按字段展示:字段名(灰底标签)+ 值(缩进 + 左边框竖线),对象/数组折叠为 JSON */
2039
+ renderResultFields(result) {
2040
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
2041
+ const keys = Object.keys(result);
2042
+ if (keys.length === 0) return react.createElement('pre', { style: { fontSize: 11 } }, '{}');
2043
+ const labelStyle = { display: 'inline-block', background: 'var(--dsw-alias-fill-2, #eef0f3)', padding: '1px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600, fontFamily: 'monospace', color: 'var(--dsw-alias-label-secondary)' };
2044
+ const valueStyle = { fontSize: 12.5, color: 'var(--dsw-alias-label-primary)', wordBreak: 'break-all', whiteSpace: 'pre-wrap', marginTop: 4, paddingLeft: 10, borderLeft: '2px solid var(--dsw-alias-border-l2, #e2e6ec)' };
2045
+ return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
2046
+ keys.map((k) => {
2047
+ const v = result[k];
2048
+ const simple = v == null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
2049
+ return react.createElement('div', { key: k },
2050
+ react.createElement('div', { style: labelStyle }, k),
2051
+ simple
2052
+ ? react.createElement('div', { style: valueStyle }, v == null ? String(v) : String(v))
2053
+ : react.createElement('pre', { style: { ...valueStyle, fontSize: 11, maxHeight: 160, overflow: 'auto', background: 'var(--dsw-alias-fill-2, #f5f6f8)', padding: 6, borderRadius: 4, marginLeft: 10, borderLeft: '2px solid var(--dsw-alias-border-l2, #e2e6ec)' } }, JSON.stringify(v, null, 2)),
2054
+ );
2055
+ }),
2056
+ );
2057
+ }
2058
+ // 数组/标量等非对象结果:保持 JSON 展示
2059
+ return react.createElement('pre', { style: { fontSize: 11 } }, JSON.stringify(result, null, 2));
2060
+ }
2061
+ /** 从产物里识别文件路径并渲染为可点击的「查看文件」列表 */
1906
2062
  renderFiles(stageId, result) {
1907
2063
  const files = [...collectFiles(result)];
1908
2064
  if (!files.length) return null;
1909
2065
  return react.createElement('div', null,
1910
- react.createElement('div', { className: 'knj-node-sec' }, '文件'),
2066
+ react.createElement('div', { className: 'knj-node-sec' }, '文件(产物路径)'),
1911
2067
  files.map((f) => react.createElement('div', { key: f, className: 'knj-row', style: { marginTop: 6, gap: 8 } },
1912
- react.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: f }, f),
1913
- react.createElement('button', { className: 'knj-btn', style: { flexShrink: 0 }, onClick: () => this.viewFile(stageId, f) }, '查看'),
2068
+ react.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: f }, '📄 ' + f),
2069
+ react.createElement('button', { className: 'knj-btn', style: { flexShrink: 0, borderColor: 'var(--dsw-static-blue-600)', color: 'var(--dsw-static-blue-600)' }, title: '读取并查看该文件内容', onClick: () => this.viewFile(stageId, f) }, '查看文件'),
1914
2070
  )),
1915
2071
  );
1916
2072
  }
@@ -1965,6 +2121,7 @@ console.log('[knj-workflow] render cwdOptions:', cwdOptions.length);
1965
2121
  ),
1966
2122
  react.createElement('button', { className: 'knj-btn', title: '刷新', onClick: () => this.refresh() }, icon('refresh'), '刷新'),
1967
2123
  react.createElement('span', { className: 'knj-badge', style: { background: statusMeta.color } }, statusMeta.label),
2124
+ task.model ? react.createElement('span', { className: 'knj-badge', style: { background: '#5b6472' }, title: '该任务固定的执行模型(创建时指定)' }, task.model) : null,
1968
2125
  ),
1969
2126
  // 整体进度条 + 摘要
1970
2127
  react.createElement('div', { className: 'knj-progress', style: { width: '100%', marginTop: 12 } },