dsh-knj-workflow 2026.8.280 → 2026.8.282

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
@@ -574,7 +574,8 @@ window.__ModuleLoader__.load({
574
574
  return u;
575
575
  });
576
576
  const edges = wf.edges.map((e) => ({ ...e, from: e.from === oldId ? newId : e.from, to: e.to === oldId ? newId : e.to }));
577
- this.setState({ wf: { ...wf, nodes, edges } });
577
+ // 同步选中态:正在编辑的节点改名后 selectedId 仍指向旧 id 会让属性面板消失
578
+ this.setState({ wf: { ...wf, nodes, edges }, selectedId: this.state.selectedId === oldId ? newId : this.state.selectedId });
578
579
  toast('阶段编码已更新为 ' + newId + ';prompt 里的 ${' + oldId + '.字段} 引用请手动改为 ${' + newId + '.字段}');
579
580
  }
580
581
  addNode(type) {
@@ -1304,7 +1305,7 @@ window.__ModuleLoader__.load({
1304
1305
  fieldLabel('节点标题(显示名)', '节点在画布和阶段列表里显示的名称,如「需求分析」。仅作展示,不影响引用。', '画布与阶段列表里的显示名'),
1305
1306
  react.createElement('input', { className: 'knj-input', style: { marginBottom: 8 }, placeholder: '如:需求分析', value: node.title || '', onChange: (e) => this.patchNode(node.id, { title: e.target.value }) }),
1306
1307
  fieldLabel('节点编码(id)', '节点唯一标识:下游用 ${编码.字段} 引用(如 ${analyze.description}),网关用字段值做分支判断。修改后 prompt 里旧的 ${旧编码.字段} 引用需手动更新。', '下游用 ${编码.字段} 引用,如 ${analyze.description}'),
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()) }),
1308
+ react.createElement('input', { key: 'node-code-' + node.id, className: 'knj-input', style: { marginBottom: 8 }, placeholder: '如:analyze', defaultValue: node.id || '', onBlur: (e) => this.patchNodeId(node.id, e.target.value.trim()) }),
1308
1309
  isTask ? react.createElement('div', null,
1309
1310
  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}、${上游编码.字段}(ⓘ 看完整清单)'),
1310
1311
  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 } }) }),
@@ -1367,7 +1368,7 @@ window.__ModuleLoader__.load({
1367
1368
  react.createElement('option', { value: 'boolean' }, 'boolean'),
1368
1369
  react.createElement('option', { value: 'array' }, 'array'),
1369
1370
  ),
1370
- type === 'string' ? react.createElement('input', { className: 'knj-input', style: { flex: 1, minWidth: 0 }, placeholder: '枚举(逗号分隔,可选)', defaultValue: (schema?.enum || []).join(','), onBlur: (e) => this.patchOutputFieldEnum(node.id, field, e.target.value) }) : null,
1371
+ type === 'string' ? react.createElement('input', { key: 'enum-' + node.id + '-' + field, className: 'knj-input', style: { flex: 1, minWidth: 0 }, placeholder: '枚举(逗号分隔,可选)', defaultValue: (schema?.enum || []).join(','), onBlur: (e) => this.patchOutputFieldEnum(node.id, field, e.target.value) }) : null,
1371
1372
  ),
1372
1373
  // 字段说明:直接输入框编辑(含义 / 格式 / 来源,注入 subagent 的输出 schema),不再用弹窗
1373
1374
  react.createElement('input', { className: 'knj-input', style: { marginTop: 4, fontSize: 12 }, placeholder: '字段说明(含义 / 格式 / 来源,会注入 subagent 的输出 schema)', value: desc, onChange: (e) => this.patchOutputFieldDescription(node.id, field, e.target.value) }),
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 回归测试:编排时切换流程节点,节点编码被覆盖(曾报告为 bug)。
3
+ *
4
+ * 修复前(client.js line 1307):节点编码输入框是非受控输入(defaultValue + onBlur->patchNodeId)
5
+ * 且没有 key,切换 selectedId 时 React 复用 DOM 元素,输入框残留上一个节点的编码,
6
+ * blur 时把残留值提交给新选中节点 → 编码被覆盖。同类的还有输出字段枚举输入框(line 1370)。
7
+ * 修复:两处输入框都补上按 node.id 区分的 key,强制切换节点时重挂载;
8
+ * 另 patchNodeId 同步 selectedId,避免编辑当前节点编码后面板消失。
9
+ *
10
+ * 覆盖路径:
11
+ * A) 切换节点后输入框残留上一个节点的值(显示被覆盖)
12
+ * B) 残留值在 blur 时提交到新节点
13
+ * C) 同类根因:输出字段枚举输入框切换节点后残留上一个节点的枚举
14
+ * D) 编辑当前节点编码后选中态应保持(selectedId 跟随新 id)
15
+ *
16
+ * 运行(需 react/react-dom/jsdom):REPRO_NODE_MODULES=<含依赖的node_modules> node repro-node-code-overwrite.mjs
17
+ * 退出码 0 = 全部通过。
18
+ */
19
+ import { createRequire } from 'node:module';
20
+ import { join } from 'node:path';
21
+ // 依赖 react/react-dom/jsdom。为避免解析到宿主环境的 react,优先用
22
+ // REPRO_NODE_MODULES 显式指定依赖目录(如装有这三者的 node_modules)。
23
+ const depDir = process.env.REPRO_NODE_MODULES || '';
24
+ const base = depDir ? join(depDir, 'repro-base.js') : import.meta.url;
25
+ const require = createRequire(base);
26
+ const React = require('react');
27
+ const { createRoot } = require('react-dom/client');
28
+ const { JSDOM } = require('jsdom');
29
+
30
+ const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true });
31
+ globalThis.window = dom.window;
32
+ globalThis.document = dom.window.document;
33
+ Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true });
34
+
35
+ const toasts = [];
36
+ const patchNodeIdImpl = (wf, oldId, newId) => {
37
+ if (!newId || newId === oldId) return { wf, blocked: false, renamed: false };
38
+ if (!/^[a-zA-Z0-9._-]+$/.test(newId) || newId.includes('..')) { toasts.push('阶段编码只能含字母/数字/._-'); return { wf, blocked: false, renamed: false }; }
39
+ if (wf.nodes.some((n) => n.id === newId && n.id !== oldId)) { toasts.push('阶段编码已存在'); return { wf, blocked: true, renamed: false }; }
40
+ const nodes = wf.nodes.map((n) => (n.id === oldId ? { ...n, id: newId } : n));
41
+ return { wf: { ...wf, nodes }, blocked: false, renamed: true };
42
+ };
43
+
44
+ class Panel extends React.Component {
45
+ constructor(props) {
46
+ super(props);
47
+ this.state = { wf: props.wf, selectedId: null };
48
+ }
49
+ select(id) { this.setState({ selectedId: id }); }
50
+ patchNodeId(oldId, newId) {
51
+ const r = patchNodeIdImpl(this.state.wf, oldId, newId);
52
+ if (r.renamed || r.blocked) this.setState({ wf: r.wf, selectedId: this.state.selectedId === oldId ? newId : this.state.selectedId });
53
+ return r;
54
+ }
55
+ patchOutputFieldEnum(nodeId, field, text) {
56
+ const values = String(text || '').split(',').map((s) => s.trim()).filter(Boolean);
57
+ const wf = this.state.wf;
58
+ const nodes = wf.nodes.map((n) => {
59
+ if (n.id !== nodeId) return n;
60
+ const props = { ...(n.body?.output?.properties || {}) };
61
+ const old = props[field] || {};
62
+ props[field] = values.length ? { ...old, enum: values } : old;
63
+ return { ...n, body: { ...n.body, output: { type: 'object', properties: props } } };
64
+ });
65
+ this.setState({ wf: { ...wf, nodes } });
66
+ }
67
+ render() {
68
+ const node = this.state.wf.nodes.find((n) => n.id === this.state.selectedId);
69
+ return React.createElement('div', null,
70
+ node ? React.createElement('div', null,
71
+ // 节点编码输入(client.js line 1307 同款;修复后带 key 强制按节点重挂载)
72
+ React.createElement('input', {
73
+ id: 'code-input',
74
+ className: 'knj-input',
75
+ key: 'node-code-' + node.id,
76
+ defaultValue: node.id || '',
77
+ onBlur: (e) => this.patchNodeId(node.id, e.target.value.trim()),
78
+ }),
79
+ // 输出字段枚举输入(client.js line 1370 同款;修复后带 key 强制按节点+字段重挂载)
80
+ React.createElement('input', {
81
+ id: 'enum-input',
82
+ key: 'enum-' + node.id + '-level',
83
+ defaultValue: (node.body?.output?.properties?.['level']?.enum || []).join(','),
84
+ onBlur: (e) => this.patchOutputFieldEnum(node.id, 'level', e.target.value),
85
+ }),
86
+ ) : null,
87
+ );
88
+ }
89
+ }
90
+
91
+ function makeWf(over = {}) {
92
+ const wf = {
93
+ id: 'wf',
94
+ nodes: [
95
+ { id: 'start', type: 'start' },
96
+ { id: 'analyze', type: 'task', title: '分析', body: { output: { type: 'object', properties: { level: { type: 'string', enum: ['high', 'low'] } } } } },
97
+ { id: 'design', type: 'task', title: '设计', body: { output: { type: 'object', properties: { level: { type: 'string', enum: ['a', 'b'] } } } } },
98
+ { id: 'end', type: 'end' },
99
+ ],
100
+ edges: [],
101
+ };
102
+ return JSON.parse(JSON.stringify({ ...wf, ...over }));
103
+ }
104
+
105
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
106
+
107
+ const container = document.getElementById('root');
108
+ const root = createRoot(container);
109
+ const ref = React.createRef();
110
+ root.render(React.createElement(Panel, { wf: makeWf(), ref }));
111
+ await sleep(50); // 等待 React 18 提交渲染
112
+
113
+ let failures = 0;
114
+ const check = (name, cond, detail) => {
115
+ console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${cond ? '' : ' → ' + detail}`);
116
+ if (!cond) failures++;
117
+ };
118
+
119
+ const input = () => document.getElementById('code-input');
120
+ const enumInput = () => document.getElementById('enum-input');
121
+
122
+ // ---- 步骤 1:选中 analyze ----
123
+ ref.current.select('analyze');
124
+ await sleep(20);
125
+ check('选中 analyze 后节点编码显示 analyze', input().value === 'analyze', `实际 ${JSON.stringify(input().value)}`);
126
+
127
+ // ---- 步骤 2(路径 A):切换选中 design,输入框必须刷新为 design ----
128
+ ref.current.select('design');
129
+ await sleep(20);
130
+ check('切换 design 后节点编码显示 design(不被上一个节点覆盖)', input().value === 'design', `实际 ${JSON.stringify(input().value)}`);
131
+
132
+ // ---- 步骤 3(路径 B):切换节点后 blur 会把残留值提交给新选中节点 ----
133
+ // 真实浏览器行为(React 官方文档):uncontrolled input 的 defaultValue 只在挂载时生效,
134
+ // 切换节点 re-render 不会同步 DOM value,输入框残留上一个节点的编码。
135
+ // 此时任何 blur(点击画布/保存/其他节点)都会把残留值提交给新选中节点。
136
+ function fireBlur(el) {
137
+ // React 18 onBlur 监听 focusout(冒泡)。不调用 focus(),避免 jsdom + React
138
+ // 的 input value polyfill 需要 attachEvent 的兼容问题。
139
+ const evt = new dom.window.Event('focusout', { bubbles: true, cancelable: true });
140
+ Object.defineProperty(evt, 'target', { value: el });
141
+ el.dispatchEvent(evt);
142
+ }
143
+ ref.current.select('analyze');
144
+ await sleep(20);
145
+ ref.current.select('design'); // 切到 design,修复后输入框应刷新为 "design"
146
+ await sleep(20);
147
+ const staleValue = input().value;
148
+ const designIdBefore = ref.current.state.wf.nodes.find((n) => n.id === 'design') ? 'design' : '(design 已被改名)';
149
+ fireBlur(input()); // 用户点击别处 → onBlur 用当前输入框值调用 patchNodeId('design', value)
150
+ await sleep(30);
151
+ const designAfterBlur = ref.current.state.wf.nodes.find((n) => n.id === 'design');
152
+ check('blur 提交的是当前节点的编码(design),而非上一个节点的残留值', staleValue === 'design', `输入框值=${JSON.stringify(staleValue)}`);
153
+ check('blur 后 design 编码保持 design(未被覆盖)', !!designAfterBlur && designAfterBlur.id === 'design', `design 现 id=${designAfterBlur ? designAfterBlur.id : '(已被改名)'}`);
154
+ console.log(' toasts:', JSON.stringify(toasts));
155
+
156
+ // ---- 步骤 4(路径 C):输出字段枚举切换节点后残留 ----
157
+ ref.current.select('analyze');
158
+ await sleep(20);
159
+ check('选中 analyze 后枚举输入显示 high,low', enumInput().value === 'high,low', `实际 ${JSON.stringify(enumInput().value)}`);
160
+ ref.current.select('design');
161
+ await sleep(20);
162
+ check('切换 design 后枚举输入显示 a,b(不被覆盖)', enumInput().value === 'a,b', `实际 ${JSON.stringify(enumInput().value)}`);
163
+
164
+ // ---- 步骤 5(路径 D):编辑当前选中节点的编码后,选中态应保持 ----
165
+ // 真实代码 patchNodeId 只 setState({wf}),不更新 selectedId:
166
+ // 选中 analyze 时把编码改为 ana → selectedId 仍是 "analyze" → 属性面板 find 不到节点而消失。
167
+ ref.current.select('analyze');
168
+ await sleep(20);
169
+ check('编辑前属性面板渲染中(输入框存在)', !!input(), '输入框不存在');
170
+ ref.current.patchNodeId('analyze', 'ana'); // 模拟用户在输入框输入 ana 后 blur
171
+ await sleep(30);
172
+ check('编辑编码后节点已改名 ana', !!ref.current.state.wf.nodes.find((n) => n.id === 'ana'), '节点未改名');
173
+ check('编辑编码后选中态保持(selectedId 跟随新 id)', ref.current.state.selectedId === 'ana', `selectedId=${JSON.stringify(ref.current.state.selectedId)}`);
174
+ check('编辑编码后面板仍渲染该节点(输入框仍存在)', !!input() && input().value === 'ana', `输入框=${input() ? JSON.stringify(input().value) : '(不存在)'}`);
175
+
176
+ console.log(failures === 0 ? '\n全部通过 ✓' : `\n${failures} 项失败 ✗`);
177
+ root.unmount();
178
+ process.exit(failures === 0 ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-knj-workflow",
3
- "version": "2026.8.280",
3
+ "version": "2026.8.282",
4
4
  "description": "KNJ 开发任务编排插件:配置驱动的工作流 + 开发任务管理 + 可视化阶段进度(DeepSeek Harness)",
5
5
  "author": "knj",
6
6
  "license": "MIT",
@@ -28,11 +28,7 @@
28
28
  "client": {
29
29
  "platform": "web",
30
30
  "inject": [
31
- "@deepseek-ai/dsh-client-runtime",
32
- "@deepseek-ai/dsh-client-locale",
33
- "@deepseek-ai/dsh-client-ui-slots",
34
- "@deepseek-ai/dsh-client-ui-conversation",
35
- "@deepseek-ai/dsh-client-modules"
31
+ "slots"
36
32
  ]
37
33
  }
38
34
  },