@mzzsfy/dsh-rs-workflow 0.2.4 → 1.0.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/src/client.js ADDED
@@ -0,0 +1,1269 @@
1
+ // rs-workflow client 半区:会话页签「若水编排」(gui-session,唯一运行记录视图)+
2
+ // 设置页分区「若水工作流」:流程模板(gui-editor)/配置(gui-config)。
3
+ // 自注册 __ModuleLoader__.load(对齐 v3);数据经 /api/rsww/* 读写。
4
+ // 类名前缀 rsww-;颜色仅取官方 alias token;开关遵循仓库规约(track+thumb,锚定 checkbox)。
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: '@mzzsfy/dsh-rs-workflow',
8
+ factory(require) {
9
+ const React = require('react')
10
+ const { useState, useEffect, useCallback } = React
11
+
12
+ const API = '/api/rsww/'
13
+ const REFRESH_MS = 5 * 1000
14
+ const LIVE_REFRESH_MS = 2 * 1000
15
+ const VIEW_ID = 'rsww-flow'
16
+ const SETTLED_COUNT = 8
17
+ const ARMED_TIMEOUT_MS = 3 * 1000
18
+
19
+ const NAV_ICON = { '若水工作流': 'flow', 'dsh-rs-workflow': 'flow' }
20
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
21
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
22
+ else window.__navicIconQueue = [NAV_ICON]
23
+
24
+ const STATUS_META = {
25
+ running: { label: '运行中', tone: 'business' },
26
+ waiting_approval: { label: '待审批', tone: 'warn' },
27
+ paused: { label: '已暂停', tone: 'warn' },
28
+ completed: { label: '已完成', tone: 'success' },
29
+ cancelled: { label: '已取消', tone: 'mute' },
30
+ failed: { label: '失败', tone: 'error' },
31
+ blocked: { label: '受阻', tone: 'error' },
32
+ }
33
+ const TERMINAL_STATES = ['completed', 'cancelled', 'failed', 'blocked']
34
+ const EVENT_LABELS = { dispatch: '派发', submit: '产出', fail: '失败', skip: '跳过', control: '控制' }
35
+
36
+ const CSS = `
37
+ .rsww-root{max-width:860px;margin:0 auto;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary,#0f1115)}
38
+ .rsww-flow{max-width:860px;margin:0 auto;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary,#0f1115);padding-top:10px}
39
+ .rsww-head{display:flex;flex-direction:column;gap:2px;padding:4px 2px 0}
40
+ .rsww-head__title{font:600 16px/24px var(--dsw-font-family,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif)}
41
+ .rsww-head__caption{font:400 var(--dsw-font-xs-13,13px/20px sans-serif);color:var(--dsw-alias-label-tertiary,#81858c)}
42
+ .rsww-btn{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-secondary,#61666b);font:500 var(--dsw-font-xs-13,13px/20px sans-serif);border-radius:8px;padding:5px 12px;cursor:pointer;transition:background .15s,border-color .15s,color .15s}
43
+ .rsww-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06));color:var(--dsw-alias-label-primary,#0f1115)}
44
+ .rsww-btn--danger{color:var(--dsw-alias-state-error-primary,#ec1313)}
45
+ .rsww-btn--danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(236,19,19,.06));border-color:var(--dsw-alias-state-error-primary,#ec1313)}
46
+ .rsww-btn:disabled{opacity:.45;cursor:default}
47
+ .rsww-badge{display:inline-flex;align-items:center;gap:5px;font:500 var(--dsw-font-xxs-12,12px/18px sans-serif);border-radius:999px;padding:2px 9px;white-space:nowrap}
48
+ .rsww-badge--business{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#4176e6) 12%,transparent);color:var(--dsw-alias-state-business-primary,#4176e6)}
49
+ .rsww-badge--success{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#22c55e) 12%,transparent);color:var(--dsw-alias-state-success-primary,#16a34a)}
50
+ .rsww-badge--error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#ec1313) 10%,transparent);color:var(--dsw-alias-state-error-primary,#ec1313)}
51
+ .rsww-badge--warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#f59e0b) 14%,transparent);color:var(--dsw-alias-state-warn-primary,#b45309)}
52
+ .rsww-badge--mute{background:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06));color:var(--dsw-alias-label-tertiary,#81858c)}
53
+ .rsww-dot{width:6px;height:6px;border-radius:50%;flex:none;background:currentColor}
54
+ .rsww-card{border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:10px;background:var(--dsw-alias-bg-base,#fff);padding:12px 14px;display:flex;flex-direction:column;gap:6px}
55
+ .rsww-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
56
+ .rsww-req{font:600 var(--dsw-font-s-14,14px/22px sans-serif);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:180px}
57
+ .rsww-group{display:flex;flex-direction:column;gap:8px}
58
+ .rsww-group__head{display:flex;align-items:baseline;gap:8px;padding:2px 2px 0}
59
+ .rsww-group__title{font:600 var(--dsw-font-xs-13,13px/20px sans-serif);color:var(--dsw-alias-label-secondary,#61666b)}
60
+ .rsww-group__count{font:400 var(--dsw-font-xxs-12,12px/18px sans-serif);color:var(--dsw-alias-label-caption,#adb2b8)}
61
+ .rsww-section{display:flex;flex-direction:column;gap:4px}
62
+ .rsww-label{font:600 var(--dsw-font-xxs-12,12px/18px sans-serif);color:var(--dsw-alias-label-tertiary,#81858c);letter-spacing:.02em}
63
+ .rsww-text{font:400 var(--dsw-font-xs-13,13px/20px sans-serif);line-height:1.6;white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-primary,#0f1115)}
64
+ .rsww-mono{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px}
65
+ .rsww-error{font:400 var(--dsw-font-xs-13,13px/20px sans-serif);color:var(--dsw-alias-state-error-primary,#ec1313)}
66
+ .rsww-empty{display:flex;flex-direction:column;align-items:center;gap:8px;padding:44px 0 40px;color:var(--dsw-alias-label-tertiary,#81858c)}
67
+ .rsww-empty__title{font:500 var(--dsw-font-s-14,14px/22px sans-serif);color:var(--dsw-alias-label-secondary,#61666b)}
68
+ .rsww-empty__hint{font:400 var(--dsw-font-xs-13,13px/20px sans-serif);text-align:center;max-width:420px}
69
+ .rsww-input{border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:8px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#0f1115);font:400 var(--dsw-font-xs-13,13px/20px sans-serif);padding:6px 10px;transition:border-color .15s}
70
+ .rsww-input:focus{outline:none;border-color:var(--dsw-alias-state-business-primary,#4176e6)}
71
+ .rsww-input--wide{flex:1;min-width:220px}
72
+ .rsww-steps{display:flex;flex-direction:column;gap:2px;margin:0;padding:0;list-style:none;max-height:420px;overflow:auto}
73
+ .rsww-step{display:flex;align-items:baseline;gap:8px;font:400 var(--dsw-font-xs-13,13px/20px sans-serif);line-height:1.55;padding:4px 6px;border-radius:6px}
74
+ .rsww-step:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06))}
75
+ .rsww-step__node{font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;color:var(--dsw-alias-label-tertiary,#81858c);flex:none;min-width:88px}
76
+ .rsww-step__sum{color:var(--dsw-alias-label-primary,#0f1115);word-break:break-word;flex:1;min-width:0}
77
+ .rsww-step--fail .rsww-step__sum{color:var(--dsw-alias-state-error-primary,#ec1313)}
78
+ .rsww-full{white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;line-height:1.6;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:8px;padding:8px;margin-top:4px;max-height:300px;overflow:auto;background:var(--dsw-alias-bg-module-platform,#f5f6f7)}
79
+ .rsww-switch{display:inline-flex;align-items:center;gap:6px;cursor:pointer;user-select:none}
80
+ .rsww-switch input[type="checkbox"]{position:absolute;opacity:0;width:1px;height:1px}
81
+ .rsww-switch__track{width:30px;height:17px;border-radius:999px;background:var(--dsw-alias-border-l3,rgba(0,0,0,.12));position:relative;transition:background .15s;flex:none}
82
+ .rsww-switch__thumb{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:transform .15s;box-shadow:0 1px 2px rgba(0,0,0,.2)}
83
+ .rsww-switch input[type="checkbox"]:checked + .rsww-switch__track{background:var(--dsw-alias-state-business-primary,#4176e6)}
84
+ .rsww-switch input[type="checkbox"]:checked + .rsww-switch__track .rsww-switch__thumb{transform:translateX(13px)}
85
+ .rsww-switch input[type="checkbox"]:focus-visible + .rsww-switch__track{outline:2px solid color-mix(in srgb,var(--dsw-alias-state-business-primary,#4176e6) 70%,white);outline-offset:1px}
86
+ .rsww-pills{display:inline-flex;gap:4px;padding:3px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:9px;background:var(--dsw-alias-bg-module-platform,#f5f6f7);width:max-content}
87
+ .rsww-pill{border:none;background:transparent;color:var(--dsw-alias-label-secondary,#61666b);font:500 var(--dsw-font-xs-13,13px/20px sans-serif);border-radius:6px;padding:4px 12px;cursor:pointer;white-space:nowrap}
88
+ .rsww-pill--on{background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#0f1115);box-shadow:0 1px 3px rgba(0,0,0,.1)}
89
+ .rsww-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
90
+ .rsww-detail{border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:10px;background:var(--dsw-alias-bg-base,#fff);padding:12px 14px;display:flex;flex-direction:column;gap:10px}
91
+ .rsww-modal{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-start;justify-content:center;background:color-mix(in srgb,var(--dsw-alias-label-primary,#0f1115) 40%,transparent);padding:40px 16px;overflow:auto}
92
+ .rsww-modal__panel{width:100%;max-width:860px;background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:10px;box-shadow:0 8px 40px color-mix(in srgb,var(--dsw-alias-label-primary,#0f1115) 18%,transparent)}
93
+ .rsww-card--err{border-color:var(--dsw-alias-state-error-primary,#ec1313)}
94
+ .rsww-input--err{border-color:var(--dsw-alias-state-error-primary,#ec1313)}
95
+ .rsww-tree__row{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap;font:400 var(--dsw-font-xs-13,13px/20px sans-serif)}
96
+ .rsww-note{font:400 var(--dsw-font-xxs-12,12px/18px sans-serif);color:var(--dsw-alias-label-caption,#adb2b8)}
97
+ .rsww-kv{display:flex;flex-direction:column;gap:6px}
98
+ .rsww-kv__row{display:flex;gap:6px;align-items:center;min-width:0}
99
+ .rsww-stack{display:flex;flex-direction:column;gap:10px}
100
+ .rsww-textarea{border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:8px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#0f1115);font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;line-height:1.6;padding:8px 10px;min-height:110px;resize:vertical;width:100%;box-sizing:border-box}
101
+ .rsww-textarea:focus{outline:none;border-color:var(--dsw-alias-state-business-primary,#4176e6)}
102
+ `
103
+
104
+ async function request(path) {
105
+ let res
106
+ try { res = await fetch(API + path) } catch (e) { return { ok: false, error: String((e && e.message) || e) } }
107
+ let json = null
108
+ try { json = await res.json() } catch { json = null }
109
+ if (!res.ok) return { ok: false, error: json && json.error ? json.error : '请求失败(' + res.status + ')' }
110
+ return { ok: true, data: json }
111
+ }
112
+
113
+ async function post(path, body) {
114
+ let res
115
+ try {
116
+ res = await fetch(API + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) })
117
+ } catch (e) { return { ok: false, error: String((e && e.message) || e) } }
118
+ let json = null
119
+ try { json = await res.json() } catch { json = null }
120
+ if (!res.ok) return { ok: false, error: json && json.error ? json.error : '请求失败(' + res.status + ')', data: json }
121
+ return { ok: true, data: json }
122
+ }
123
+
124
+ function statusMeta(status) { return STATUS_META[status] || { label: status || '-', tone: 'mute' } }
125
+
126
+ function relativeTime(ts) {
127
+ if (typeof ts !== 'number' || !(ts > 0)) return '-'
128
+ const delta = Date.now() - ts
129
+ const abs = Math.abs(delta)
130
+ const MINUTE = 60 * 1000
131
+ const HOUR = 60 * MINUTE
132
+ const DAY = 24 * HOUR
133
+ const suffix = delta >= 0 ? '前' : '后'
134
+ if (abs < MINUTE) return Math.max(1, Math.round(abs / 1000)) + ' 秒' + suffix
135
+ if (abs < HOUR) return Math.round(abs / MINUTE) + ' 分钟' + suffix
136
+ if (abs < DAY) return Math.round(abs / HOUR) + ' 小时' + suffix
137
+ return Math.round(abs / DAY) + ' 天' + suffix
138
+ }
139
+
140
+ function Badge({ tone, children, dot, title }) {
141
+ return React.createElement('span', { className: 'rsww-badge rsww-badge--' + tone, title },
142
+ dot ? React.createElement('span', { className: 'rsww-dot' }) : null, children)
143
+ }
144
+
145
+ function StatusBadge({ status }) {
146
+ const meta = statusMeta(status)
147
+ // 待审批以裁决图标提示段边界等待裁决(gui-session 修订 2)
148
+ const icon = status === 'waiting_approval' ? '⚖ ' : ''
149
+ return React.createElement(Badge, { tone: meta.tone, dot: true }, icon + meta.label)
150
+ }
151
+
152
+ function EmptyState({ title, hint }) {
153
+ return React.createElement('div', { className: 'rsww-empty' },
154
+ React.createElement('span', { className: 'rsww-empty__title' }, title),
155
+ hint ? React.createElement('span', { className: 'rsww-empty__hint' }, hint) : null)
156
+ }
157
+
158
+ function Switch(props) {
159
+ return React.createElement('label', { className: 'rsww-switch' },
160
+ React.createElement('input', { type: 'checkbox', checked: props.checked, onChange: props.onChange, 'aria-label': props.ariaLabel }),
161
+ React.createElement('span', { className: 'rsww-switch__track' }, React.createElement('span', { className: 'rsww-switch__thumb' })),
162
+ React.createElement('span', { className: 'rsww-text' }, props.label))
163
+ }
164
+
165
+ // 产出/指令/错误全文展开(痛点 7/18:零截断,默认折叠)
166
+ // 剪贴板写入:clipboard API 优先;非安全上下文(LAN HTTP)退化 execCommand
167
+ const copyText = async (text) => {
168
+ if (navigator.clipboard?.writeText) {
169
+ try { await navigator.clipboard.writeText(text); return true } catch { /* 权限拒绝时走兜底 */ }
170
+ }
171
+ try {
172
+ const ta = document.createElement('textarea')
173
+ ta.value = text
174
+ ta.style.position = 'fixed'
175
+ ta.style.opacity = '0'
176
+ document.body.appendChild(ta)
177
+ ta.select()
178
+ const done = document.execCommand('copy')
179
+ ta.remove()
180
+ return done
181
+ } catch { return false }
182
+ }
183
+
184
+ function CopyButton({ text, label = '复制' }) {
185
+ const [done, setDone] = useState(false)
186
+ useEffect(() => {
187
+ if (!done) return undefined
188
+ const t = setTimeout(() => setDone(false), ARMED_TIMEOUT_MS)
189
+ return () => clearTimeout(t)
190
+ }, [done])
191
+ return React.createElement('button', { className: 'rsww-btn', style: { padding: '2px 8px' }, type: 'button',
192
+ onClick: async () => { if (await copyText(text)) setDone(true) } },
193
+ done ? '已复制' : label)
194
+ }
195
+
196
+ function OutputFullText({ label, text, tone }) {
197
+ const [open, setOpen] = useState(false)
198
+ if (!text) return null
199
+ return React.createElement('div', {},
200
+ React.createElement('button', { className: 'rsww-btn', style: { padding: '2px 8px' }, onClick: () => setOpen((v) => !v) },
201
+ (open ? '收起 ' : '展开 ') + label),
202
+ open ? React.createElement('div', { className: 'rsww-full', style: tone === 'error' ? { color: 'var(--dsw-alias-state-error-primary,#ec1313)' } : null }, text) : null,
203
+ open ? React.createElement(CopyButton, { text }) : null)
204
+ }
205
+
206
+ // 步骤事件账行:按 store.step 落盘顺序渲染全量 body;body 判空兜底(空账/异常事件不崩页签)
207
+ // v5:裁决类 control 带 by 来源徽标(真人/代审,代审 reason 悬浮全文)
208
+ function StreamRow({ stepId, instance, event, body }) {
209
+ const safeBody = body || {}
210
+ const tag = stepId + (instance && instance !== '-' ? '#' + instance : '')
211
+ const kind = EVENT_LABELS[event] || event
212
+ const isVerdict = event === 'control' && (safeBody.kind === 'approve' || safeBody.kind === 'reject')
213
+ let summary = ''
214
+ if (event === 'dispatch') summary = safeBody.callLabel ? String(safeBody.callLabel) : '→ ' + tag
215
+ else if (event === 'submit') summary = '产出已提交'
216
+ else if (event === 'fail') summary = safeBody.error || '失败'
217
+ else if (event === 'control') summary = (safeBody.kind === 'message' ? (safeBody.inject ? '[纠偏注入] ' : '[排队] ') : '[' + safeBody.kind + '] ') + (safeBody.text || '')
218
+ else summary = safeBody.reason || ''
219
+ return React.createElement('li', { className: 'rsww-step' + (event === 'fail' ? ' rsww-step--fail' : '') },
220
+ React.createElement('span', { className: 'rsww-step__node' }, tag + ' · ' + kind),
221
+ isVerdict ? React.createElement(Badge, { tone: safeBody.by === 'main-agent' ? 'business' : 'warn', title: safeBody.reason || undefined },
222
+ safeBody.by === 'main-agent' ? '代审' : '真人') : null,
223
+ React.createElement('span', { className: 'rsww-step__sum' },
224
+ isVerdict ? (safeBody.kind === 'approve' ? '通过' : '驳回') + (safeBody.reason || '') : summary,
225
+ event === 'dispatch' && safeBody.prompt ? React.createElement(OutputFullText, { label: '指令全文', text: safeBody.prompt }) : null,
226
+ event === 'submit' && safeBody.outputs ? React.createElement(OutputFullText, { label: '产出全文', text: JSON.stringify(safeBody.outputs, null, 2) }) : null,
227
+ event === 'fail' ? React.createElement(OutputFullText, { label: '错误详情', text: safeBody.error || '' }) : null))
228
+ }
229
+
230
+ // 控制条:按状态显隐按钮 + 审批裁决行(waiting_approval,提交带 by:user)+ 末端消息输入行(勾选「纠偏注入」)
231
+ // v5:waiting_approval 双入口提示(页签裁决与主循环裁决先到先得);paused 恢复后提示待代理推进;
232
+ // 驳回两步(先填审批意见再确认,意见入 redo.comments 服务重做说明),意见可空
233
+ function ControlBar({ run }) {
234
+ const [text, setText] = useState('')
235
+ const [inject, setInject] = useState(false)
236
+ const [armed, setArmed] = useState(false)
237
+ const [resumed, setResumed] = useState(false)
238
+ const [rejecting, setRejecting] = useState(false)
239
+ const [rejectText, setRejectText] = useState('')
240
+ const [error, setError] = useState('')
241
+ const live = run.status === 'running' || run.status === 'paused' || run.status === 'waiting_approval'
242
+ useEffect(() => {
243
+ if (!armed) return undefined
244
+ const timer = setTimeout(() => setArmed(false), ARMED_TIMEOUT_MS)
245
+ return () => clearTimeout(timer)
246
+ }, [armed])
247
+ useEffect(() => {
248
+ if (run.status !== 'waiting_approval') { setRejecting(false); setRejectText('') }
249
+ }, [run.status])
250
+ if (!live) return null
251
+ const control = async (kind, extra) => {
252
+ setError('')
253
+ const outcome = await post('control', { runId: run.runId, kind, ...(extra || {}) })
254
+ if (!outcome.ok) { setError(outcome.error); return false }
255
+ if (outcome.data && outcome.data.ok === false) { setError('未受理(状态已变化或已被处理)'); return false }
256
+ if (kind === 'resume') setResumed(true)
257
+ return true
258
+ }
259
+ const send = async () => {
260
+ if (!text.trim()) return
261
+ setError('')
262
+ const outcome = await post('control', { runId: run.runId, kind: 'message', text: text.trim(), inject })
263
+ if (!outcome.ok) { setError(outcome.error); return }
264
+ if (outcome.data && outcome.data.ok === false) { setError('未受理(状态已变化或已被处理)'); return }
265
+ setText('')
266
+ }
267
+ const confirmReject = async () => {
268
+ const accepted = await control('reject', { by: 'user', reason: rejectText.trim() })
269
+ if (accepted) { setRejecting(false); setRejectText('') }
270
+ }
271
+ const verdictButtons = run.status === 'waiting_approval'
272
+ ? React.createElement('div', { className: 'rsww-section' },
273
+ React.createElement('div', { className: 'rsww-row' },
274
+ React.createElement('span', { className: 'rsww-note' }, '页签裁决与主循环裁决(代审或 ask_user 转呈)先到先得'),
275
+ React.createElement('button', { className: 'rsww-btn', onClick: () => control('approve', { by: 'user' }) }, '通过'),
276
+ rejecting
277
+ ? null
278
+ : React.createElement('button', { className: 'rsww-btn rsww-btn--danger', onClick: () => setRejecting(true) }, '驳回')),
279
+ rejecting ? React.createElement('div', { className: 'rsww-row' },
280
+ React.createElement('input', {
281
+ className: 'rsww-input rsww-input--wide', value: rejectText,
282
+ placeholder: '驳回意见(可空;将作为审批意见注入重做批次)',
283
+ autoFocus: true,
284
+ onChange: (e) => setRejectText(e.target.value),
285
+ onKeyDown: (e) => { if (e.key === 'Enter') confirmReject() },
286
+ }),
287
+ React.createElement('button', { className: 'rsww-btn rsww-btn--danger', onClick: confirmReject }, '确认驳回'),
288
+ React.createElement('button', { className: 'rsww-btn', onClick: () => { setRejecting(false); setRejectText('') } }, '取消')) : null)
289
+ : null
290
+ return React.createElement('div', { className: 'rsww-section' },
291
+ React.createElement('div', { className: 'rsww-row' },
292
+ run.status === 'running' ? React.createElement('button', { className: 'rsww-btn', onClick: () => control('pause') }, '暂停') : null,
293
+ run.status === 'paused' ? React.createElement('button', { className: 'rsww-btn', onClick: () => control('resume') }, '恢复') : null,
294
+ armed
295
+ ? React.createElement('button', { className: 'rsww-btn rsww-btn--danger', onClick: () => control('cancel') }, '确认取消')
296
+ : React.createElement('button', { className: 'rsww-btn rsww-btn--danger', onClick: () => setArmed(true) }, '取消')),
297
+ verdictButtons,
298
+ resumed && run.status === 'paused' ? React.createElement('div', { className: 'rsww-row' },
299
+ React.createElement('span', { className: 'rsww-note' }, '已恢复,待代理推进,请在会话中发言唤醒')) : null,
300
+ React.createElement('div', { className: 'rsww-row' },
301
+ React.createElement('input', {
302
+ className: 'rsww-input rsww-input--wide', value: text, placeholder: '追加消息(运行中入队,下一步进入编排)',
303
+ onChange: (e) => setText(e.target.value),
304
+ onKeyDown: (e) => { if (e.key === 'Enter') send() },
305
+ }),
306
+ React.createElement('button', { className: 'rsww-btn', onClick: send, disabled: !text.trim() }, '发送'),
307
+ React.createElement(Switch, { checked: inject, onChange: (e) => setInject(e.target.checked), label: '纠偏注入', ariaLabel: '纠偏注入' })),
308
+ error ? React.createElement('span', { className: 'rsww-error' }, error) : null)
309
+ }
310
+
311
+ // 单 run 卡片:详情自轮询(运行中 2s;落定仅挂载一次);终态卡内嵌重跑/续跑/删除操作
312
+ function RunCard({ run, onChanged }) {
313
+ const [detail, setDetail] = useState(null)
314
+ const [fetchError, setFetchError] = useState(false)
315
+ const live = run.status === 'running' || run.status === 'paused' || run.status === 'waiting_approval'
316
+ const reload = useCallback(() => request('run?id=' + encodeURIComponent(run.runId)).then((outcome) => {
317
+ if (outcome.ok) { setDetail(outcome.data); setFetchError(false) } else setFetchError(true)
318
+ }), [run.runId])
319
+ useEffect(() => {
320
+ let alive = true
321
+ const load = () => reload().then(() => { if (!alive) return })
322
+ load()
323
+ const timer = live ? setInterval(load, LIVE_REFRESH_MS) : null
324
+ return () => { alive = false; if (timer) clearInterval(timer) }
325
+ }, [reload, live])
326
+ const view = detail || run
327
+ const queued = Array.isArray(view.queued) ? view.queued : []
328
+ const warnings = Array.isArray(view.warnings) ? view.warnings : []
329
+ const stepEvents = []
330
+ const steps = view.stepsTrace || {}
331
+ for (const [stepId, byInstance] of Object.entries(steps)) {
332
+ for (const [instance, events] of Object.entries(byInstance || {})) {
333
+ for (const e of events || []) stepEvents.push({ stepId, instance, ...e })
334
+ }
335
+ }
336
+ stepEvents.sort((a, b) => (a.at || 0) - (b.at || 0))
337
+ const requestText = String(view.request || '').split(/<\/?system-reminder>/i)[0].trim() || '(见完整记录)'
338
+ const settled = TERMINAL_STATES.includes(view.status)
339
+ // v5:待审批摘要(waiting 负载:审批步/目标/要点口径/轮次);plan.source 规划标识;warnings 徽标悬浮全文
340
+ const waiting = view.waiting && typeof view.waiting === 'object' ? view.waiting : null
341
+ return React.createElement('div', { className: 'rsww-card' },
342
+ React.createElement('div', { className: 'rsww-row' },
343
+ React.createElement(StatusBadge, { status: view.status }),
344
+ view.templateId ? React.createElement(Badge, { tone: 'mute' }, view.templateId) : null,
345
+ view.plan && view.plan.source === 'model' ? React.createElement(Badge, { tone: 'business' }, '模型规划') : null,
346
+ view.plan && view.plan.source === 'fallback' ? React.createElement(Badge, { tone: 'mute' }, '模板全序') : null,
347
+ warnings.length ? React.createElement('span', { title: warnings.map((w) => (w && w.message) || String(w)).join('\n') },
348
+ React.createElement(Badge, { tone: 'warn' }, '警告 ' + warnings.length)) : null,
349
+ React.createElement('span', { className: 'rsww-req', title: view.request }, requestText),
350
+ React.createElement('span', { className: 'rsww-text' }, relativeTime(view.updatedAt || view.createdAt))),
351
+ waiting ? React.createElement('div', { className: 'rsww-section' },
352
+ React.createElement('span', { className: 'rsww-label' }, '待审批 · ' + (waiting.stepId || '-')),
353
+ React.createElement('span', { className: 'rsww-text' },
354
+ '目标:' + ((waiting.target && (waiting.target.label || waiting.target.id)) || waiting.target || '-'),
355
+ typeof waiting.rounds === 'number' ? ' · 第 ' + waiting.rounds + ' 轮' : ''),
356
+ waiting.brief ? React.createElement('span', { className: 'rsww-text' }, '要点:' + waiting.brief) : null,
357
+ waiting.comments ? React.createElement('span', { className: 'rsww-text' }, '上轮意见:' + waiting.comments) : null) : null,
358
+ React.createElement(ControlBar, { run: view }),
359
+ queued.length ? React.createElement('div', { className: 'rsww-section' },
360
+ React.createElement('span', { className: 'rsww-label' }, '排队消息'),
361
+ queued.map((q, i) => React.createElement('span', { className: 'rsww-text', key: i }, '[' + (i + 1) + '] ' + q))) : null,
362
+ stepEvents.length ? React.createElement('div', { className: 'rsww-section' },
363
+ React.createElement('span', { className: 'rsww-label' }, '批次节点流'),
364
+ React.createElement('ul', { className: 'rsww-steps' },
365
+ stepEvents.map((e, i) => React.createElement(StreamRow, { key: i, ...e })))) : null,
366
+ settled && view.summary ? React.createElement('div', { className: 'rsww-section' },
367
+ React.createElement('span', { className: 'rsww-label' }, '结论'),
368
+ React.createElement('span', { className: 'rsww-text' }, view.summary),
369
+ view.plan ? React.createElement('span', { className: 'rsww-note' },
370
+ ' · ' + (view.plan.source === 'model' ? '模型规划' : '模板全序')) : null) : null,
371
+ settled && detail ? React.createElement(DetailActions, { detail, onReload: reload, onRemoved: onChanged }) : null,
372
+ fetchError ? React.createElement('span', { className: 'rsww-error' }, '详情拉取失败,下轮自动重试') : null)
373
+ }
374
+
375
+ function FlowView({ getSessionId }) {
376
+ const [runs, setRuns] = useState(null)
377
+ const [error, setError] = useState('')
378
+ const reload = useCallback(async () => {
379
+ const outcome = await request('runs')
380
+ if (outcome.ok) { setRuns(outcome.data.runs || []); setError('') } else setError(outcome.error)
381
+ }, [])
382
+ useEffect(() => {
383
+ reload()
384
+ const timer = setInterval(reload, REFRESH_MS)
385
+ return () => clearInterval(timer)
386
+ }, [reload])
387
+ if (error && !runs) return React.createElement('div', { className: 'rsww-flow' }, React.createElement('span', { className: 'rsww-error' }, error))
388
+ // 只呈现本会话的 run:页签语义=本会话编排在场;跨会话检索交给宿主会话搜索
389
+ const mine = (runs || []).filter((r) => r.sessionId === getSessionId())
390
+ const running = mine.filter((r) => r.status === 'running' || r.status === 'paused' || r.status === 'waiting_approval')
391
+ const settled = mine.filter((r) => !running.includes(r)).slice(0, SETTLED_COUNT)
392
+ const group = (title, count, items) => React.createElement('div', { className: 'rsww-group' },
393
+ React.createElement('div', { className: 'rsww-group__head' },
394
+ React.createElement('span', { className: 'rsww-group__title' }, title),
395
+ React.createElement('span', { className: 'rsww-group__count' }, count)),
396
+ items.map((run) => React.createElement(RunCard, { key: run.runId, run, onChanged: reload })))
397
+ return React.createElement('div', { className: 'rsww-flow' },
398
+ React.createElement('style', { 'data-plugin': '@mzzsfy/dsh-rs-workflow', dangerouslySetInnerHTML: { __html: CSS } }),
399
+ error ? React.createElement('span', { className: 'rsww-error' }, error) : null,
400
+ mine.length === 0 ? EmptyState({ title: '本会话还没有运行记录', hint: '在若水模式下发送需求后,编排进度会实时出现在这里。' }) : null,
401
+ running.length ? group('进行中', running.length + ' 次', running) : null,
402
+ settled.length ? group('已落定', '近 ' + settled.length + ' 次', settled) : null)
403
+ }
404
+
405
+ // ── 设置页共享件:pill 组/行编辑/钳制(gui-center/gui-editor/gui-config 同源) ──
406
+ const h = React.createElement
407
+ const SLOT_KEYS = ['planner', 'executor', 'reviewer', 'executor-loop', 'reviewer-approve', 'executor-escalate']
408
+ const BUDGET_KEYS = ['maxStepFail', 'approveRounds', 'escalateLimit']
409
+ const BUDGET_MIN = 1
410
+ const BUDGET_MAX = 10
411
+
412
+ function PillGroup({ value, options, onChange, tab, ariaLabel }) {
413
+ return h('div', { className: 'rsww-pills', role: tab ? 'tablist' : undefined, 'aria-label': ariaLabel },
414
+ options.map((o) => h('button', {
415
+ key: o.key, type: 'button', role: tab ? 'tab' : undefined,
416
+ className: 'rsww-pill' + (value === o.key ? ' rsww-pill--on' : ''),
417
+ 'aria-pressed': value === o.key, onClick: () => onChange(o.key),
418
+ }, o.label)))
419
+ }
420
+
421
+ function clampRange(v, min, max) {
422
+ const n = Math.round(Number(v))
423
+ if (!Number.isFinite(n)) return min
424
+ return Math.min(max, Math.max(min, n))
425
+ }
426
+
427
+ // 键值行编辑(顶层 inputs/outputs/flow input/续跑 inputs 同款);空名行在序列化侧剔除
428
+ function KvRows({ rows, onChange, kPh, vPh, kMono }) {
429
+ const set = (i, part, v) => onChange(rows.map((r, j) => (j === i ? { k: r.k, v: r.v, [part]: v } : r)))
430
+ return h('div', { className: 'rsww-kv' },
431
+ rows.map((r, i) => h('div', { className: 'rsww-kv__row', key: i },
432
+ h('input', { className: 'rsww-input' + (kMono ? ' rsww-mono' : ''), value: r.k, placeholder: kPh, onChange: (e) => set(i, 'k', e.target.value) }),
433
+ h('input', { className: 'rsww-input rsww-input--wide', value: r.v, placeholder: vPh, onChange: (e) => set(i, 'v', e.target.value) }),
434
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => onChange(rows.filter((_, j) => j !== i)) }, '删除'))),
435
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => onChange(rows.concat({ k: '', v: '' })) }, '添加一行'))
436
+ }
437
+
438
+ function StrRows({ items, onChange, ph, min }) {
439
+ return h('div', { className: 'rsww-kv' },
440
+ items.map((v, i) => h('div', { className: 'rsww-kv__row', key: i },
441
+ h('input', { className: 'rsww-input rsww-mono rsww-input--wide', value: v, placeholder: ph, onChange: (e) => onChange(items.map((x, j) => (j === i ? e.target.value : x))) }),
442
+ h('button', { className: 'rsww-btn', type: 'button', disabled: min !== undefined && items.length <= min, onClick: () => onChange(items.filter((_, j) => j !== i)) }, '删除'))),
443
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => onChange(items.concat('')) }, '添加一行'))
444
+ }
445
+
446
+ // 危险操作两段确认(armed 超时自动解除)
447
+ function ArmedButton({ label, confirmLabel, onConfirm, disabled }) {
448
+ const [armed, setArmed] = useState(false)
449
+ useEffect(() => {
450
+ if (!armed) return undefined
451
+ const t = setTimeout(() => setArmed(false), ARMED_TIMEOUT_MS)
452
+ return () => clearTimeout(t)
453
+ }, [armed])
454
+ return h('button', { className: 'rsww-btn rsww-btn--danger', type: 'button', disabled: disabled || undefined,
455
+ onClick: () => { if (armed) { setArmed(false); onConfirm() } else setArmed(true) } }, armed ? confirmLabel : label)
456
+ }
457
+
458
+ // ── run 终态操作与重跑/续跑选择器(gui-session 卡片内使用) ──
459
+ const STEP_TONES = { pending: 'mute', running: 'business', done: 'success', failed: 'error', skipped: 'mute' }
460
+ const STEP_STATUS_LABELS = { pending: '待执行', running: '执行中', done: '已完成', failed: '失败', skipped: '已跳过' }
461
+
462
+ function StepBadge({ status }) {
463
+ return h(Badge, { tone: STEP_TONES[status] || 'mute' }, STEP_STATUS_LABELS[status] || status || '-')
464
+ }
465
+
466
+ function ResumePicker({ detail, init, onClose, onResumed }) {
467
+ const macroAll = (detail.state && detail.state.steps) || {}
468
+ const ids = Object.keys(macroAll)
469
+ const breakpoint = ids.find((id) => macroAll[id] && macroAll[id].status !== 'done') || ''
470
+ const radioName = 'rsww-resume-' + detail.runId
471
+ const [sel, setSel] = useState(init === 'resume' ? breakpoint : '')
472
+ const [rows, setRows] = useState(() => Object.entries(detail.inputs || {}).map(([k, v]) => ({ k, v: String(v) })))
473
+ const [busy, setBusy] = useState(false)
474
+ const [error, setError] = useState('')
475
+ const [newRunId, setNewRunId] = useState('')
476
+ const confirm = async () => {
477
+ setBusy(true); setError('')
478
+ const inputs = {}
479
+ for (const r of rows) if (r.k.trim() !== '' && r.v.trim() !== '') inputs[r.k.trim()] = r.v
480
+ const body = { runId: detail.runId }
481
+ if (sel !== '') body.fromStepId = sel
482
+ if (Object.keys(inputs).length > 0) body.inputs = inputs
483
+ const o = await post('resume-from', body)
484
+ setBusy(false)
485
+ if (!o.ok) { setError(o.error); return }
486
+ setNewRunId((o.data && o.data.runId) || '')
487
+ onResumed()
488
+ }
489
+ return h('div', { className: 'rsww-card rsww-stack' },
490
+ h('div', { className: 'rsww-row' },
491
+ h('span', { className: 'rsww-label' }, '重跑/续跑'),
492
+ h('span', { className: 'rsww-note' }, 'inputs 留空 = 不覆盖;成功后生成新 run,已完成步骤作种子')),
493
+ h('label', { className: 'rsww-tree__row' },
494
+ h('input', { type: 'radio', name: radioName, checked: sel === '', onChange: () => setSel('') }),
495
+ h('span', { className: 'rsww-text' }, '从头重跑(不携带 fromStepId)')),
496
+ ids.map((id) => h('label', { className: 'rsww-tree__row', key: id },
497
+ h('input', { type: 'radio', name: radioName, checked: sel === id, onChange: () => setSel(id) }),
498
+ h('span', { className: 'rsww-mono' }, id),
499
+ h(StepBadge, { status: macroAll[id] && macroAll[id].status }),
500
+ id === breakpoint ? h('span', { className: 'rsww-note' }, '断点') : null)),
501
+ h(KvRows, { rows, onChange: setRows, kPh: 'input 名', vPh: '值(留空该行不覆盖)', kMono: true }),
502
+ error ? h('span', { className: 'rsww-error' }, error) : null,
503
+ newRunId ? h('span', { className: 'rsww-text' }, '新 run:', h('span', { className: 'rsww-mono' }, ' ' + newRunId)) : null,
504
+ h('div', { className: 'rsww-row' },
505
+ h('button', { className: 'rsww-btn', type: 'button', onClick: confirm, disabled: busy }, busy ? '提交中' : '确认'),
506
+ h('button', { className: 'rsww-btn', type: 'button', onClick: onClose }, '关闭')))
507
+ }
508
+
509
+ // 终态操作行:重跑/断点续跑/删除(RunCard 落定卡内嵌;删除成功经 onRemoved 通知列表刷新)
510
+ function DetailActions({ detail, onReload, onRemoved }) {
511
+ const [picker, setPicker] = useState('')
512
+ const [error, setError] = useState('')
513
+ const remove = async () => {
514
+ setError('')
515
+ const o = await post('run-remove', { runId: detail.runId })
516
+ if (!o.ok) { setError(/404/.test(o.error) ? '宿主不支持删除路由(404),记录保留' : o.error); return }
517
+ onRemoved()
518
+ }
519
+ return h('div', { className: 'rsww-section' },
520
+ h('div', { className: 'rsww-row' },
521
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => setPicker(picker === 'rerun' ? '' : 'rerun') }, '重跑'),
522
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => setPicker(picker === 'resume' ? '' : 'resume') }, '断点续跑'),
523
+ h(ArmedButton, { label: '删除', confirmLabel: '确认删除', onConfirm: remove })),
524
+ error ? h('span', { className: 'rsww-error' }, error) : null,
525
+ picker !== '' ? h(ResumePicker, { detail, init: picker, onClose: () => setPicker(''), onResumed: onReload }) : null)
526
+ }
527
+
528
+ // ── gui-editor 模板编辑器:权威态=编辑模型,文本派生+显式应用(host dryRun 权威校验) ──
529
+ const TYPE_OPTIONS = [{ key: 'ai', label: '普通' }, { key: 'approve', label: 'approve' }, { key: 'flow', label: 'flow' }]
530
+ const MODE_OPTIONS = [{ key: 'sequential', label: '顺序' }, { key: 'parallel', label: '并行' }]
531
+ const TEMPLATE_ID_RE = /^[a-z][a-z0-9-]*$/
532
+ const asStr = (v) => (typeof v === 'string' ? v : '')
533
+ const asArr = (v) => (Array.isArray(v) ? v.slice() : [])
534
+ const objToRows = (v) => Object.entries(v && typeof v === 'object' && !Array.isArray(v) ? v : {}).map(([k, x]) => ({ k, v: typeof x === 'string' ? x : JSON.stringify(x) }))
535
+ const rowsToObj = (rows) => Object.fromEntries((rows || []).filter((r) => r.k.trim() !== '').map((r) => [r.k.trim(), r.v]))
536
+ const outNamesOf = (s) => s.outputs.map((r) => r.k.trim()).filter((n) => n !== '')
537
+
538
+ const newStep = () => ({ id: '', label: '', type: 'ai', slot: '', prompt: '', outputs: [], listOutputs: [], load: [], after: [], maxFail: '', for_each: '', mode: '', target: '', rounds: '', onExhausted: '', flow: '', input: [] })
539
+
540
+ function normalizeStep(s) {
541
+ const base = newStep()
542
+ if (!s || typeof s !== 'object') return base
543
+ return {
544
+ ...base,
545
+ id: asStr(s.id), label: asStr(s.label),
546
+ type: ['ai', 'approve', 'flow'].includes(s.type) ? s.type : 'ai',
547
+ slot: asStr(s.slot), prompt: asStr(s.prompt),
548
+ outputs: objToRows(s.outputs), listOutputs: asArr(s.listOutputs), load: asArr(s.load), after: asArr(s.after),
549
+ maxFail: Number.isInteger(s.maxFail) ? s.maxFail : '',
550
+ for_each: asStr(s.for_each), mode: asStr(s.mode),
551
+ target: asStr(s.target), rounds: Number.isInteger(s.rounds) ? s.rounds : '',
552
+ onExhausted: asStr(s.onExhausted), flow: asStr(s.flow), input: objToRows(s.input),
553
+ }
554
+ }
555
+
556
+ // 序列化裁剪:空值省略;type=ai 省略(type 缺省即 ai);enabled 不入文(校验器禁)
557
+ function stepOut(s) {
558
+ const o = { id: s.id }
559
+ if (s.label !== '') o.label = s.label
560
+ if (s.type !== 'ai') o.type = s.type
561
+ if (s.slot !== '') o.slot = s.slot
562
+ if (s.type !== 'flow' && s.prompt !== '') o.prompt = s.prompt
563
+ if (s.after.length > 0) o.after = s.after
564
+ if (s.type === 'ai') {
565
+ if (outNamesOf(s).length > 0) o.outputs = rowsToObj(s.outputs)
566
+ if (s.listOutputs.length > 0) o.listOutputs = s.listOutputs
567
+ if (s.load.length > 0) o.load = s.load
568
+ if (s.maxFail !== '') o.maxFail = Number(s.maxFail)
569
+ if (s.for_each !== '') { o.for_each = s.for_each; o.mode = s.mode === 'parallel' ? 'parallel' : 'sequential' }
570
+ }
571
+ if (s.type === 'approve') {
572
+ if (s.target !== '') o.target = s.target
573
+ if (s.rounds !== '') o.rounds = Number(s.rounds)
574
+ if (s.onExhausted !== '') o.onExhausted = s.onExhausted
575
+ }
576
+ if (s.type === 'flow') {
577
+ if (s.flow !== '') o.flow = s.flow
578
+ if (s.input.some((r) => r.k.trim() !== '')) o.input = rowsToObj(s.input)
579
+ }
580
+ return o
581
+ }
582
+
583
+ function serializeModel(m) {
584
+ return JSON.stringify({
585
+ id: m.id,
586
+ ...(m.label !== '' ? { label: m.label } : {}),
587
+ ...(m.description !== '' ? { description: m.description } : {}),
588
+ ...(m.inputs.some((r) => r.k.trim() !== '') ? { inputs: rowsToObj(m.inputs) } : {}),
589
+ steps: m.steps.map(stepOut),
590
+ }, null, 2)
591
+ }
592
+
593
+ // 文本可 JSON.parse 即建全模型;否则文本模式(synced=false,结构表单锁定,应用后回填)。
594
+ // 副本条目(id 空)仅继承结构,meta(id/label/description)由用户重填,防覆盖原模板
595
+ function modelFromEntry(entry) {
596
+ let parsed = null
597
+ try { parsed = JSON.parse(entry.json) } catch { parsed = null }
598
+ const meta = { id: entry.id || '', label: asStr(entry.label) || entry.id || '', description: asStr(entry.description), enabled: entry.enabled !== false }
599
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
600
+ return { model: { ...meta, inputs: [], steps: [] }, text: entry.json, synced: false }
601
+ }
602
+ const model = {
603
+ id: meta.id !== '' ? (asStr(parsed.id) || meta.id) : '',
604
+ label: meta.id !== '' ? (asStr(parsed.label) || meta.label) : meta.label,
605
+ description: meta.id !== '' ? (asStr(parsed.description) || meta.description) : meta.description,
606
+ enabled: meta.enabled,
607
+ inputs: objToRows(parsed.inputs),
608
+ steps: asArr(parsed.steps).map(normalizeStep),
609
+ }
610
+ return { model, text: serializeModel(model), synced: true }
611
+ }
612
+
613
+ // 前端即时校验(保存/应用前);step: 空 id 的错误平铺展示
614
+ function clientErrors(m) {
615
+ const errs = []
616
+ if (!TEMPLATE_ID_RE.test(m.id || '')) errs.push({ target: 'top:id', message: 'id 必填且匹配 ^[a-z][a-z0-9-]*$' })
617
+ if (m.steps.length === 0) errs.push({ target: 'top:steps', message: '至少一个步骤' })
618
+ const seen = new Set()
619
+ for (const s of m.steps) {
620
+ const at = s.id !== '' ? 'step:' + s.id : 'step:'
621
+ if (!TEMPLATE_ID_RE.test(s.id || '')) errs.push({ target: at, message: '步骤 id 必填且匹配 ^[a-z][a-z0-9-]*$' })
622
+ else if (seen.has(s.id)) errs.push({ target: at, message: '步骤 id 重复:' + s.id })
623
+ if (s.id !== '') seen.add(s.id)
624
+ if (s.type !== 'flow' && s.prompt.trim() === '') errs.push({ target: at, message: (s.id || '未命名步骤') + ':prompt 必填' })
625
+ if (s.type === 'ai' && outNamesOf(s).length === 0) errs.push({ target: at, message: (s.id || '未命名步骤') + ':outputs 产出契约必填' })
626
+ if (s.type === 'approve' && s.target === '') errs.push({ target: at, message: (s.id || '未命名步骤') + ':approve 步骤 target 必填' })
627
+ if (s.type === 'flow' && s.flow.trim() === '') errs.push({ target: at, message: (s.id || '未命名步骤') + ':flow 必填' })
628
+ }
629
+ return errs
630
+ }
631
+
632
+ const stripForType = (s) => {
633
+ if (s.type === 'approve') return { ...s, outputs: [], listOutputs: [], load: [], for_each: '', mode: '', flow: '', input: [] }
634
+ if (s.type === 'flow') return { ...s, prompt: '', outputs: [], listOutputs: [], load: [], for_each: '', mode: '', target: '', rounds: '', onExhausted: '' }
635
+ return { ...s, target: '', rounds: '', onExhausted: '', flow: '', input: [] }
636
+ }
637
+
638
+ function MetaForm({ model, update, synced, topErr }) {
639
+ const idErrs = topErr('id')
640
+ const field = (label, key, mono, disabled) => h('div', { className: 'rsww-kv__row' },
641
+ h('span', { className: 'rsww-label', style: { width: 64, flex: 'none' } }, label),
642
+ h('input', { className: 'rsww-input' + (mono ? ' rsww-mono' : '') + ' rsww-input--wide' + (key === 'id' && idErrs.length > 0 ? ' rsww-input--err' : ''),
643
+ value: model[key], disabled: disabled || undefined, onChange: (e) => update((m) => ({ ...m, [key]: e.target.value })) }))
644
+ return h('div', { className: 'rsww-kv' },
645
+ field('id', 'id', true, false),
646
+ idErrs.map((e, i) => h('span', { className: 'rsww-error', key: i }, e.message)),
647
+ field('显示名', 'label', false, false),
648
+ field('说明', 'description', false, false),
649
+ h(Switch, { checked: model.enabled, onChange: (e) => update((m) => ({ ...m, enabled: e.target.checked })), label: '启用(禁用后不可创建、重跑不可选)', ariaLabel: '启用模板' }),
650
+ synced ? null : h('span', { className: 'rsww-note' }, '此模板文本含 JSON5 扩展语法,结构表单锁定:请在文本视图点「应用」,经 host 校验通过后回填。'))
651
+ }
652
+
653
+ function InputsForm({ model, update, topErr }) {
654
+ const errs = topErr('inputs')
655
+ return h('div', { className: 'rsww-section' },
656
+ h('span', { className: 'rsww-label' }, '顶层 inputs(prompt 内 {input.name} 引用)'),
657
+ errs.map((e, i) => h('span', { className: 'rsww-error', key: i }, e.message)),
658
+ h(KvRows, { rows: model.inputs, onChange: (rows) => update((m) => ({ ...m, inputs: rows })), kPh: 'name', vPh: '说明', kMono: true }))
659
+ }
660
+
661
+ function ListOutSelect({ step, onChange }) {
662
+ const names = outNamesOf(step)
663
+ if (names.length === 0) return h('span', { className: 'rsww-note' }, 'listOutputs:先声明 outputs 后可选列表字段')
664
+ const toggle = (n) => onChange({ ...step, listOutputs: step.listOutputs.includes(n) ? step.listOutputs.filter((x) => x !== n) : step.listOutputs.concat(n) })
665
+ return h('div', { className: 'rsww-section' },
666
+ h('span', { className: 'rsww-label' }, 'listOutputs(列表型产出,供 for_each 引用)'),
667
+ names.map((n) => h('label', { className: 'rsww-switch', key: n },
668
+ h('input', { type: 'checkbox', checked: step.listOutputs.includes(n), onChange: () => toggle(n), 'aria-label': n }),
669
+ h('span', { className: 'rsww-switch__track' }, h('span', { className: 'rsww-switch__thumb' })),
670
+ h('span', { className: 'rsww-mono' }, n))))
671
+ }
672
+
673
+ function AfterSelect({ step, otherIds, onChange }) {
674
+ if (otherIds.length === 0) return h('span', { className: 'rsww-note' }, 'after:暂无其他步骤可选')
675
+ const toggle = (id) => onChange({ ...step, after: step.after.includes(id) ? step.after.filter((x) => x !== id) : step.after.concat(id) })
676
+ return h('div', { className: 'rsww-section' },
677
+ h('span', { className: 'rsww-label' }, 'after(前置步骤;缺省=文档序前一步)'),
678
+ otherIds.map((id) => h('label', { className: 'rsww-switch', key: id },
679
+ h('input', { type: 'checkbox', checked: step.after.includes(id), onChange: () => toggle(id), 'aria-label': id }),
680
+ h('span', { className: 'rsww-switch__track' }, h('span', { className: 'rsww-switch__thumb' })),
681
+ h('span', { className: 'rsww-mono' }, id))))
682
+ }
683
+
684
+ function ForEachRow({ step, sources, onChange }) {
685
+ return h('div', { className: 'rsww-section' },
686
+ h('div', { className: 'rsww-kv__row' },
687
+ h('span', { className: 'rsww-label', style: { width: 64, flex: 'none' } }, 'for_each'),
688
+ h('select', { className: 'rsww-input', value: step.for_each, onChange: (e) => onChange({ ...step, for_each: e.target.value }) },
689
+ h('option', { value: '' }, '(不循环)'),
690
+ sources.map((o) => h('option', { key: o, value: o }, o)))),
691
+ step.for_each !== '' ? h('div', { className: 'rsww-kv__row' },
692
+ h('span', { className: 'rsww-label', style: { width: 64, flex: 'none' } }, 'mode'),
693
+ h(PillGroup, { value: step.mode === 'parallel' ? 'parallel' : 'sequential', options: MODE_OPTIONS, onChange: (v) => onChange({ ...step, mode: v }), ariaLabel: '循环模式' })) : null)
694
+ }
695
+
696
+ function ApproveForm({ step, otherIds, onChange }) {
697
+ return h('div', { className: 'rsww-stack' },
698
+ h('div', { className: 'rsww-kv__row' },
699
+ h('span', { className: 'rsww-label', style: { width: 64, flex: 'none' } }, 'target'),
700
+ h('select', { className: 'rsww-input', value: step.target, onChange: (e) => onChange({ ...step, target: e.target.value }) },
701
+ h('option', { value: '' }, '(必选:被审批步骤)'),
702
+ otherIds.map((id) => h('option', { key: id, value: id }, id)))),
703
+ h('div', { className: 'rsww-kv__row' },
704
+ h('span', { className: 'rsww-label', style: { width: 64, flex: 'none' } }, 'rounds'),
705
+ h('input', { className: 'rsww-input', type: 'number', value: step.rounds, placeholder: '留空=预算 approveRounds',
706
+ onChange: (e) => onChange({ ...step, rounds: e.target.value === '' ? '' : Number(e.target.value) }),
707
+ onBlur: () => { if (step.rounds !== '') onChange({ ...step, rounds: clampRange(step.rounds, BUDGET_MIN, BUDGET_MAX) }) } })),
708
+ h('div', { className: 'rsww-kv__row' },
709
+ h('span', { className: 'rsww-label', style: { width: 64, flex: 'none' } }, 'onExhausted'),
710
+ h('select', { className: 'rsww-input', value: step.onExhausted, onChange: (e) => onChange({ ...step, onExhausted: e.target.value }) },
711
+ h('option', { value: '' }, '(缺省 blocked)'),
712
+ h('option', { value: 'blocked' }, 'blocked'),
713
+ otherIds.map((id) => h('option', { key: id, value: id }, id)))))
714
+ }
715
+
716
+ function FlowForm({ step, templates, onChange }) {
717
+ return h('div', { className: 'rsww-stack' },
718
+ h('div', { className: 'rsww-kv__row' },
719
+ h('span', { className: 'rsww-label', style: { width: 64, flex: 'none' } }, 'flow'),
720
+ h('input', { className: 'rsww-input rsww-mono rsww-input--wide', list: 'rsww-tmpl-ids', value: step.flow,
721
+ placeholder: '模板 id 或 {步骤.产出}', onChange: (e) => onChange({ ...step, flow: e.target.value }) })),
722
+ h('div', { className: 'rsww-section' },
723
+ h('span', { className: 'rsww-label' }, 'input(子流程入参,单占位符引用)'),
724
+ h(KvRows, { rows: step.input, onChange: (rows) => onChange({ ...step, input: rows }), kPh: '入参名', vPh: '{步骤.产出}', kMono: true })))
725
+ }
726
+
727
+ function StepCard({ step, index, model, errors, onChange, onRemove }) {
728
+ const [openSelf, setOpenSelf] = useState(false)
729
+ const [adv, setAdv] = useState(false)
730
+ const open = openSelf || errors.length > 0
731
+ const otherIds = [...new Set(model.steps.map((s) => s.id).filter((id) => id !== '' && id !== step.id))]
732
+ const sources = []
733
+ for (const s of model.steps) {
734
+ if (s.id !== '' && s.id !== step.id && s.type === 'ai') for (const out of s.listOutputs) sources.push(s.id + '.' + out)
735
+ }
736
+ const row = (label, control) => h('div', { className: 'rsww-kv__row' },
737
+ h('span', { className: 'rsww-label', style: { width: 64, flex: 'none' } }, label), control)
738
+ return h('div', { className: 'rsww-card' + (errors.length > 0 ? ' rsww-card--err' : '') },
739
+ h('div', { className: 'rsww-row' },
740
+ h('button', { className: 'rsww-btn', type: 'button', style: { padding: '2px 8px' }, onClick: () => setOpenSelf(!openSelf) },
741
+ (open ? '收起 ' : '展开 ') + (step.id !== '' ? step.id : '#' + (index + 1))),
742
+ h(Badge, { tone: 'mute' }, (TYPE_OPTIONS.find((t) => t.key === step.type) || TYPE_OPTIONS[0]).label),
743
+ h('span', { style: { flex: 1 } }),
744
+ h(ArmedButton, { label: '删除步骤', confirmLabel: '确认删除', onConfirm: onRemove })),
745
+ errors.map((e, i) => h('span', { className: 'rsww-error', key: i }, e.message)),
746
+ !open ? null : h('div', { className: 'rsww-stack' },
747
+ row('类型', h(PillGroup, { value: step.type, options: TYPE_OPTIONS, onChange: (t) => onChange(stripForType({ ...step, type: t })), ariaLabel: '步骤类型' })),
748
+ row('id', h('input', { className: 'rsww-input rsww-mono rsww-input--wide', value: step.id, placeholder: '步骤 id(小写字母开头)', onChange: (e) => onChange({ ...step, id: e.target.value }) })),
749
+ row('显示名', h('input', { className: 'rsww-input rsww-input--wide', value: step.label, placeholder: '可空', onChange: (e) => onChange({ ...step, label: e.target.value }) })),
750
+ step.type !== 'approve' ? row('slot', h('select', { className: 'rsww-input', value: step.slot, onChange: (e) => onChange({ ...step, slot: e.target.value }) },
751
+ h('option', { value: '' }, '(引擎缺省)'), SLOT_KEYS.map((k) => h('option', { key: k, value: k }, k)))) : null,
752
+ step.type !== 'flow' ? row('prompt', h('textarea', { className: 'rsww-textarea', value: step.prompt, rows: 4,
753
+ placeholder: '指令全文;占位符 {request}/{input.x}/{步骤.产出}{item}', onChange: (e) => onChange({ ...step, prompt: e.target.value }) })) : null,
754
+ step.type === 'ai' ? h('div', { className: 'rsww-section' },
755
+ h('span', { className: 'rsww-label' }, 'outputs 产出契约'),
756
+ h(KvRows, { rows: step.outputs, onChange: (rows) => onChange({ ...step, outputs: rows }), kPh: '字段名', vPh: '说明(结构化产出口径)', kMono: true })) : null,
757
+ step.type === 'ai' ? h(ListOutSelect, { step, onChange }) : null,
758
+ step.type === 'approve' ? h(ApproveForm, { step, otherIds, onChange }) : null,
759
+ step.type === 'flow' ? h(FlowForm, { step, onChange }) : null,
760
+ h('div', {},
761
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => setAdv(!adv) }, adv ? '收起高级' : '高级字段(after/for_each/load/maxFail)'),
762
+ !adv ? null : h('div', { className: 'rsww-stack', style: { marginTop: 6 } },
763
+ h(AfterSelect, { step, otherIds, onChange }),
764
+ step.type === 'ai' ? h(ForEachRow, { step, sources, onChange }) : null,
765
+ step.type === 'ai' ? h('div', { className: 'rsww-section' },
766
+ h('span', { className: 'rsww-label' }, 'load 资源'),
767
+ h(StrRows, { items: step.load, onChange: (items) => onChange({ ...step, load: items }), ph: 'skill:name 或 doc:name' })) : null,
768
+ step.type === 'ai' ? row('maxFail', h('input', { className: 'rsww-input', type: 'number', value: step.maxFail, placeholder: '留空=预算 maxStepFail',
769
+ onChange: (e) => onChange({ ...step, maxFail: e.target.value === '' ? '' : Number(e.target.value) }),
770
+ onBlur: () => { if (step.maxFail !== '') onChange({ ...step, maxFail: clampRange(step.maxFail, BUDGET_MIN, BUDGET_MAX) }) } })) : null))))
771
+ }
772
+
773
+ function StepsForm({ model, update, allErrors }) {
774
+ const setSteps = (steps) => update((m) => ({ ...m, steps }))
775
+ return h('div', { className: 'rsww-stack' },
776
+ model.steps.map((s, i) => h(StepCard, {
777
+ key: i, step: s, index: i, model,
778
+ errors: allErrors.filter((e) => s.id !== '' && e.target === 'step:' + s.id),
779
+ onChange: (next) => setSteps(model.steps.map((x, j) => (j === i ? next : x))),
780
+ onRemove: () => setSteps(model.steps.filter((_, j) => j !== i)),
781
+ })),
782
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => setSteps(model.steps.concat(newStep())) }, '添加步骤'))
783
+ }
784
+
785
+ function Json5Pane({ text, setText, onApply, onValidate, jsonErrs, saving }) {
786
+ return h('div', { className: 'rsww-stack' },
787
+ h('textarea', { className: 'rsww-textarea', value: text, rows: 18, spellCheck: false, onChange: (e) => setText(e.target.value) }),
788
+ h('div', { className: 'rsww-row' },
789
+ h('button', { className: 'rsww-btn', type: 'button', onClick: onApply, disabled: saving }, '应用(校验并回填表单)'),
790
+ h('button', { className: 'rsww-btn', type: 'button', onClick: onValidate, disabled: saving }, '校验')),
791
+ jsonErrs.map((e, i) => h('span', { className: 'rsww-error', key: i },
792
+ typeof e.line === 'number' ? '行 ' + e.line + ':' + e.message : e.message)))
793
+ }
794
+
795
+ function TemplateEditor({ entry, isNew, templates, onClose, onSaved }) {
796
+ const init = isNew
797
+ ? { model: { id: '', label: '', description: '', enabled: true, inputs: [], steps: [newStep()] }, text: '', synced: true }
798
+ : modelFromEntry(entry)
799
+ const [model, setModel] = useState(init.model)
800
+ const [text, setText] = useState(() => init.text !== '' ? init.text : serializeModel(init.model))
801
+ const [synced, setSynced] = useState(init.synced)
802
+ const [view, setView] = useState(init.synced ? 'struct' : 'json')
803
+ const [saveErrors, setSaveErrors] = useState([])
804
+ const [saving, setSaving] = useState(false)
805
+ const [note, setNote] = useState('')
806
+ const [filling, setFilling] = useState(false)
807
+ // 文本含 JSON5 扩展语法时 client 不解析(解析权威在 host):经 template 只读路由取 host 解析结果回填表单
808
+ useEffect(() => {
809
+ if (init.synced || isNew) return
810
+ let live = true
811
+ setFilling(true)
812
+ request('template?id=' + encodeURIComponent(entry.id)).then((o) => {
813
+ if (!live) return
814
+ setFilling(false)
815
+ const parsed = o.ok && o.data && o.data.parsed
816
+ if (!parsed || typeof parsed !== 'object') { setNote('结构表单不可用:host 解析失败,可在文本视图编辑并「应用」。'); return }
817
+ const m = {
818
+ id: asStr(parsed.id) || entry.id || '',
819
+ label: asStr(parsed.label), description: asStr(parsed.description),
820
+ enabled: entry.enabled !== false,
821
+ inputs: objToRows(parsed.inputs),
822
+ steps: asArr(parsed.steps).map(normalizeStep),
823
+ }
824
+ setModel(m); setSynced(true); setView('struct')
825
+ setNote('已按 host 解析结果载入结构表单;保存以「应用」后的文本为准。')
826
+ })
827
+ return () => { live = false }
828
+ }, [isNew, entry && entry.id])
829
+ // 未回填(文本模式)时表单编辑不重写文本,json 正文始终权威
830
+ const update = (fn) => {
831
+ if (!synced) { setModel((prev) => fn(prev)); return }
832
+ setModel((prev) => {
833
+ const next = fn(prev)
834
+ setText(serializeModel(next))
835
+ return next
836
+ })
837
+ }
838
+ const errsOf = (o) => (o.data && Array.isArray(o.data.errors) && o.data.errors.length > 0 ? o.data.errors : [{ target: 'json', message: o.error }])
839
+ const allErrors = clientErrors(model).filter(() => synced).concat(saveErrors)
840
+ const topErr = (field) => allErrors.filter((e) => e.target === 'top:' + field)
841
+ const jsonErrs = allErrors.filter((e) => e.target === 'json')
842
+ const flatErrs = allErrors.filter((e) => e.target === 'step:')
843
+ const dryRun = async () => {
844
+ setSaving(true); setNote(''); setSaveErrors([])
845
+ const o = await post('template-save', { id: model.id, label: model.label, description: model.description, enabled: model.enabled, json: text, dryRun: true })
846
+ setSaving(false)
847
+ if (!o.ok) { setSaveErrors(errsOf(o)); return null }
848
+ return true
849
+ }
850
+ const applyText = async () => {
851
+ if (await dryRun() === null) { setNote('校验失败,文本与表单均未修改'); return }
852
+ let parsed = null
853
+ try { parsed = JSON.parse(text) } catch { parsed = null }
854
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
855
+ setNote('校验通过;文本含 JSON5 扩展语法,表单未回填(保存仍以文本为准)')
856
+ return
857
+ }
858
+ const m = {
859
+ id: asStr(parsed.id) || model.id,
860
+ label: asStr(parsed.label), description: asStr(parsed.description),
861
+ enabled: model.enabled, inputs: objToRows(parsed.inputs),
862
+ steps: asArr(parsed.steps).map(normalizeStep),
863
+ }
864
+ setModel(m); setText(serializeModel(m)); setSynced(true)
865
+ setNote('已应用:表单按文本回填')
866
+ }
867
+ const validateText = async () => {
868
+ if (await dryRun() !== null) setNote('校验通过(未落盘)')
869
+ else setNote('校验失败')
870
+ }
871
+ const doSave = async (andRelease) => {
872
+ setNote(''); setSaveErrors([])
873
+ const local = synced ? clientErrors(model) : []
874
+ if (local.length > 0) { setSaveErrors(local); return }
875
+ setSaving(true)
876
+ const o = await post('template-save', { id: model.id, label: model.label, description: model.description, enabled: model.enabled, json: text })
877
+ if (!o.ok) { setSaving(false); setSaveErrors(errsOf(o)); setNote('保存被拒绝'); return }
878
+ if (!andRelease) { setSaving(false); onSaved(''); return }
879
+ const r = await post('release', { id: model.id })
880
+ setSaving(false)
881
+ onSaved(r.ok ? '' : '已保存;创建失败:' + r.error)
882
+ }
883
+ return h(Modal, { title: (isNew ? '新建模板' : '编辑模板 · ' + (entry.id || '(副本:待定 id)')), onClose },
884
+ h('datalist', { id: 'rsww-tmpl-ids' }, templates.map((t) => h('option', { key: t.id, value: t.id }))),
885
+ h(MetaForm, { model, update, synced, topErr }),
886
+ synced ? h(InputsForm, { model, update, topErr }) : null,
887
+ h(PillGroup, { tab: true, ariaLabel: '视图', value: view, options: [{ key: 'struct', label: '结构' }, { key: 'json', label: 'JSON5' }], onChange: setView }),
888
+ view === 'struct'
889
+ ? (synced ? h(StepsForm, { model, update, allErrors }) : h('span', { className: 'rsww-note' }, '结构表单已锁定:先在 JSON5 视图点「应用」回填。'))
890
+ : h(Json5Pane, { text, setText, onApply: applyText, onValidate: validateText, jsonErrs, saving }),
891
+ flatErrs.map((e, i) => h('span', { className: 'rsww-error', key: 'flat' + i }, e.message)),
892
+ h('div', { className: 'rsww-row' },
893
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => doSave(false), disabled: saving }, '保存'),
894
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => doSave(true), disabled: saving }, '保存并创建'),
895
+ note ? h('span', { className: 'rsww-note' }, note) : null))
896
+ }
897
+
898
+ function TemplateCard({ t, released, busy, onView, onEdit, onCopy, onRelease, onUnrelease, onDelete }) {
899
+ const builtin = t.builtin === true
900
+ const disabled = t.enabled === false
901
+ return h('div', { className: 'rsww-card' },
902
+ h('div', { className: 'rsww-row' },
903
+ h('span', { className: 'rsww-text', style: { fontWeight: 600 } }, t.label || t.id),
904
+ h('span', { className: 'rsww-mono rsww-note' }, t.id),
905
+ builtin ? h(Badge, { tone: 'business' }, '内置') : null,
906
+ disabled ? h(Badge, { tone: 'mute' }, '已禁用') : null,
907
+ released.includes(t.id) ? h(Badge, { tone: 'success' }, '已创建') : null),
908
+ t.description ? h('span', { className: 'rsww-note' }, t.description) : null,
909
+ h('div', { className: 'rsww-row' },
910
+ h('button', { className: 'rsww-btn', type: 'button', disabled: busy || undefined, onClick: onView }, '详情'),
911
+ builtin
912
+ ? h('button', { className: 'rsww-btn', type: 'button', disabled: busy || undefined, onClick: onCopy }, '另存为副本')
913
+ : h('button', { className: 'rsww-btn', type: 'button', disabled: busy || undefined, onClick: onEdit }, '编辑'),
914
+ h(ArmedButton, { label: builtin ? '禁用' : '删除', confirmLabel: builtin ? '确认禁用' : '确认删除', disabled: busy, onConfirm: onDelete }),
915
+ !disabled && released.includes(t.id) ? h(ArmedButton, { label: '移除模式', confirmLabel: '确认移除', disabled: busy, onConfirm: onUnrelease }) : null,
916
+ !disabled && !released.includes(t.id) ? h('button', { className: 'rsww-btn', type: 'button', disabled: busy || undefined, onClick: onRelease }, '创建到 dsh') : null))
917
+ }
918
+
919
+ // ── 只读详情弹窗:所有模板可点开;结构视图(JSON.parse 可达时)+JSON5 原文+一键检验 ──
920
+ function Modal({ title, onClose, children }) {
921
+ return h('div', { className: 'rsww-modal', onClick: (e) => { if (e.target === e.currentTarget) onClose() } },
922
+ h('div', { className: 'rsww-modal__panel', role: 'dialog', 'aria-label': title },
923
+ h('div', { className: 'rsww-row' },
924
+ h('span', { className: 'rsww-head__title' }, title),
925
+ h('span', { style: { flex: 1 } }),
926
+ h('button', { className: 'rsww-btn', type: 'button', onClick: onClose }, '关闭')),
927
+ children))
928
+ }
929
+
930
+ function ViewerStep({ step }) {
931
+ const [open, setOpen] = useState(false)
932
+ const typeName = (TYPE_OPTIONS.find((t) => t.key === step.type) || TYPE_OPTIONS[0]).label
933
+ const row = (label, text) => (text !== '' && text != null) ? h('div', { className: 'rsww-kv__row' },
934
+ h('span', { className: 'rsww-label', style: { width: 84, flex: 'none' } }, label),
935
+ h('span', { className: 'rsww-text', style: { whiteSpace: 'pre-wrap' } }, String(text))) : null
936
+ const kvRows = (rows) => h('div', { className: 'rsww-kv' },
937
+ rows.map((kv, i) => h('div', { className: 'rsww-kv__row', key: i },
938
+ h('span', { className: 'rsww-mono' }, kv.k),
939
+ kv.v ? h('span', { className: 'rsww-note' }, kv.v) : null)))
940
+ return h('div', { className: 'rsww-card' },
941
+ h('div', { className: 'rsww-row' },
942
+ h('button', { className: 'rsww-btn', type: 'button', style: { padding: '2px 8px' }, onClick: () => setOpen(!open) },
943
+ (open ? '收起 ' : '展开 ') + (step.id !== '' ? step.id : '#')),
944
+ h(Badge, { tone: 'mute' }, typeName),
945
+ step.type === 'ai' && outNamesOf(step).length > 0 ? h('span', { className: 'rsww-note' }, outNamesOf(step).join('/')) : null),
946
+ !open ? null : h('div', { className: 'rsww-stack' },
947
+ row('显示名', step.label),
948
+ row('slot', step.slot),
949
+ step.type !== 'flow' ? row('prompt', step.prompt) : null,
950
+ step.type === 'ai' && step.outputs.length > 0 ? h('div', { className: 'rsww-section' },
951
+ h('span', { className: 'rsww-label' }, 'outputs 产出契约'),
952
+ kvRows(step.outputs),
953
+ step.listOutputs.length > 0 ? h('span', { className: 'rsww-note' }, 'listOutputs: ' + step.listOutputs.join(', ')) : null) : null,
954
+ step.type === 'approve' ? h('div', { className: 'rsww-stack' },
955
+ row('target', step.target), row('rounds', step.rounds), row('onExhausted', step.onExhausted || '(缺省 blocked)')) : null,
956
+ step.type === 'flow' ? h('div', { className: 'rsww-stack' },
957
+ row('flow', step.flow),
958
+ step.input.length > 0 ? h('div', { className: 'rsww-section' },
959
+ h('span', { className: 'rsww-label' }, 'input(子流程入参)'), kvRows(step.input)) : null) : null,
960
+ step.after.length > 0 ? row('after', step.after.join(', ')) : null,
961
+ step.for_each !== '' ? h('div', { className: 'rsww-stack' },
962
+ row('for_each', step.for_each), row('mode', step.mode === 'parallel' ? 'parallel' : 'sequential')) : null,
963
+ step.load.length > 0 ? row('load', step.load.join(', ')) : null,
964
+ step.maxFail !== '' ? row('maxFail', step.maxFail) : null))
965
+ }
966
+
967
+ function TemplateViewer({ entry, onClose }) {
968
+ const [data, setData] = useState(null)
969
+ const [loadErr, setLoadErr] = useState('')
970
+ const [showJson, setShowJson] = useState(false)
971
+ const [checking, setChecking] = useState(false)
972
+ const [checkNote, setCheckNote] = useState('')
973
+ const [checkErrs, setCheckErrs] = useState([])
974
+ const reload = useCallback(() => request('template?id=' + encodeURIComponent(entry.id)).then((o) => {
975
+ if (o.ok) { setData(o.data); setLoadErr('') } else setLoadErr(o.error)
976
+ }), [entry.id])
977
+ useEffect(() => { reload() }, [reload])
978
+ const validate = async () => {
979
+ setChecking(true); setCheckNote(''); setCheckErrs([])
980
+ const o = await post('template-save', { id: entry.id, label: entry.label, description: entry.description, enabled: entry.enabled !== false, json: entry.json, dryRun: true })
981
+ setChecking(false)
982
+ if (o.ok) { setCheckNote('校验通过(未落盘)'); return }
983
+ setCheckErrs(o.data && Array.isArray(o.data.errors) && o.data.errors.length > 0 ? o.data.errors : [{ target: 'json', message: o.error }])
984
+ }
985
+ const parsed = data && data.parsed
986
+ const model = parsed ? { steps: asArr(parsed.steps).map(normalizeStep), inputs: objToRows(parsed.inputs) } : null
987
+ const badge = (tone, text) => h(Badge, { tone }, text)
988
+ return h(Modal, { title: '模板详情', onClose },
989
+ h('div', { className: 'rsww-row' },
990
+ h('span', { className: 'rsww-mono rsww-note' }, entry.id),
991
+ entry.builtin === true ? badge('business', '内置') : null,
992
+ entry.enabled === false ? badge('mute', '已禁用') : null,
993
+ h('span', { style: { flex: 1 } }),
994
+ h('button', { className: 'rsww-btn', type: 'button', onClick: validate, disabled: checking }, checking ? '检验中' : '一键检验'),
995
+ checkNote ? badge('success', checkNote) : null),
996
+ entry.description ? h('span', { className: 'rsww-note' }, entry.description) : null,
997
+ loadErr ? h('span', { className: 'rsww-error' }, loadErr) : null,
998
+ !data && !loadErr ? h('span', { className: 'rsww-note' }, '加载中...') : null,
999
+ checkErrs.map((e, i) => h('span', { className: 'rsww-error', key: i }, (e.target ? e.target + ': ' : '') + e.message)),
1000
+ data && !parsed ? h('span', { className: 'rsww-note' }, '模板文本解析失败,仅展示原文与一键检验。') : null,
1001
+ model && model.inputs.length > 0 ? h('div', { className: 'rsww-section' },
1002
+ h('span', { className: 'rsww-label' }, '顶层 inputs'),
1003
+ h('div', { className: 'rsww-kv' },
1004
+ model.inputs.map((kv, i) => h('div', { className: 'rsww-kv__row', key: i },
1005
+ h('span', { className: 'rsww-mono' }, kv.k),
1006
+ kv.v ? h('span', { className: 'rsww-note' }, kv.v) : null)))) : null,
1007
+ model ? h('div', { className: 'rsww-section' },
1008
+ h('span', { className: 'rsww-label' }, '步骤(' + model.steps.length + ')'),
1009
+ model.steps.map((s, i) => h(ViewerStep, { key: i, step: s }))) : null,
1010
+ h('div', { className: 'rsww-section' },
1011
+ h('div', { className: 'rsww-row' },
1012
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => setShowJson(!showJson) }, showJson ? '收起 JSON5' : 'JSON5 全文'),
1013
+ showJson ? h(CopyButton, { text: entry.json || '' }) : null),
1014
+ showJson ? h('div', { className: 'rsww-full', style: { maxHeight: 300 } }, entry.json || '(空)') : null))
1015
+ }
1016
+
1017
+ function TemplatesPage() {
1018
+ const [templates, setTemplates] = useState(null)
1019
+ const [released, setReleased] = useState([])
1020
+ const [releasedErr, setReleasedErr] = useState('')
1021
+ const [error, setError] = useState('')
1022
+ const [editing, setEditing] = useState(null)
1023
+ const [viewing, setViewing] = useState(null)
1024
+ const [busyId, setBusyId] = useState('')
1025
+ const [specOpen, setSpecOpen] = useState(false)
1026
+ const [specText, setSpecText] = useState('')
1027
+ const [specLoaded, setSpecLoaded] = useState(false)
1028
+ const reload = useCallback(async () => {
1029
+ const [t, r] = await Promise.all([request('templates'), request('released')])
1030
+ if (!t.ok) { setError(t.error); return }
1031
+ setTemplates(t.data.templates || [])
1032
+ setError('')
1033
+ if (r.ok) { setReleased(r.data.ids || []); setReleasedErr('') } else setReleasedErr('已创建列表拉取失败,创建按钮按未创建态渲染')
1034
+ }, [])
1035
+ useEffect(() => { reload() }, [reload])
1036
+ const toggleSpec = async () => {
1037
+ const next = !specOpen
1038
+ setSpecOpen(next)
1039
+ if (next && !specLoaded) {
1040
+ const o = await request('spec')
1041
+ setSpecText(o.ok ? (o.data.spec || '') : '规范拉取失败:' + o.error)
1042
+ setSpecLoaded(o.ok)
1043
+ }
1044
+ }
1045
+ const write = async (path, body) => {
1046
+ setBusyId(body.id || '?')
1047
+ const o = await post(path, body)
1048
+ setBusyId('')
1049
+ if (!o.ok) { setError(o.error); return }
1050
+ setError('')
1051
+ await reload()
1052
+ }
1053
+ if (templates === null) {
1054
+ return h('div', { className: 'rsww-stack' },
1055
+ h('span', { className: 'rsww-error' }, error || '加载中...'),
1056
+ h('button', { className: 'rsww-btn', type: 'button', onClick: reload }, '重试'))
1057
+ }
1058
+ const groups = [['内置模板', templates.filter((t) => t.builtin === true)], ['自定义模板', templates.filter((t) => t.builtin !== true)]]
1059
+ return h('div', { className: 'rsww-stack' },
1060
+ h('div', { className: 'rsww-toolbar' },
1061
+ h('button', { className: 'rsww-btn', type: 'button', onClick: () => setEditing({ entry: null, isNew: true }) }, '新建模板'),
1062
+ h('button', { className: 'rsww-btn', type: 'button', onClick: toggleSpec }, specOpen ? '收起模板规范' : '模板规范')),
1063
+ error ? h('span', { className: 'rsww-error' }, error) : null,
1064
+ releasedErr ? h('span', { className: 'rsww-note' }, releasedErr) : null,
1065
+ specOpen ? h('div', { className: 'rsww-card' },
1066
+ h('div', { className: 'rsww-row' },
1067
+ h('span', { className: 'rsww-label' }, '模板规范'),
1068
+ specText !== '' && !/^规范拉取失败/.test(specText) ? h(CopyButton, { text: specText }) : null),
1069
+ h('div', { className: 'rsww-full', style: { maxHeight: 320 } }, specText !== '' ? specText : '加载中...')) : null,
1070
+ editing ? h(TemplateEditor, {
1071
+ entry: editing.entry, isNew: editing.isNew, templates,
1072
+ onClose: () => setEditing(null),
1073
+ onSaved: (err2) => { setEditing(null); reload(); if (err2) setError(err2) },
1074
+ }) : null,
1075
+ viewing ? h(TemplateViewer, { entry: viewing, onClose: () => setViewing(null) }) : null,
1076
+ groups.map(([title, list]) => h('div', { className: 'rsww-group', key: title },
1077
+ h('div', { className: 'rsww-group__head' },
1078
+ h('span', { className: 'rsww-group__title' }, title),
1079
+ h('span', { className: 'rsww-group__count' }, list.length + ' 个')),
1080
+ list.map((t) => h(TemplateCard, {
1081
+ key: t.id, t, released, busy: busyId === t.id,
1082
+ onView: () => setViewing(t),
1083
+ onEdit: () => setEditing({ entry: t, isNew: false }),
1084
+ onCopy: () => setEditing({ entry: { id: '', label: (t.label || t.id) + ' 副本', description: t.description || '', enabled: true, json: t.json, copyOf: t.id }, isNew: false }),
1085
+ onRelease: () => write('release', { id: t.id }),
1086
+ onUnrelease: () => write('unrelease', { id: t.id }),
1087
+ onDelete: () => write('template-remove', { id: t.id }),
1088
+ })))))
1089
+ }
1090
+
1091
+ // ── gui-config 配置编辑 ─────────────────────────────────────────────────
1092
+ // 副文案与常量镜像 lib/settings-schema.mjs(DEFAULT_CONCURRENCY/KEEP_RUNS 为 host 常量)
1093
+ const SLOT_INFO = [
1094
+ { key: 'planner', name: 'planner 规划位', desc: '大纲/计划/分诊类步骤显式绑定;留空 = 会话默认模型' },
1095
+ { key: 'executor', name: 'executor 执行位', desc: '常规步骤缺省绑定;候选失败依次轮换' },
1096
+ { key: 'reviewer', name: 'reviewer 审阅位', desc: '审批域通用绑定(approve 步固定 reviewer-approve,不降级至此)' },
1097
+ { key: 'executor-loop', name: 'executor-loop 循环位', desc: 'for_each 实例与审批重做批次的缺省绑定' },
1098
+ { key: 'reviewer-approve', name: 'reviewer-approve 审批位', desc: 'type:"approve" 步骤固定绑定,模板不可覆盖' },
1099
+ { key: 'executor-escalate', name: 'executor-escalate 升级位', desc: 'onExhausted 升级步骤的缺省绑定' },
1100
+ ]
1101
+ const BUDGET_INFO = [
1102
+ { key: 'maxStepFail', name: 'maxStepFail', desc: '步骤连续失败上限;达上限步骤 failed、下游 skipped' },
1103
+ { key: 'approveRounds', name: 'approveRounds', desc: 'approve 步骤默认重审轮次;耗尽走 onExhausted' },
1104
+ { key: 'escalateLimit', name: 'escalateLimit', desc: 'run 级升级账上限;达上限整流程 blocked' },
1105
+ ]
1106
+ const DEFAULT_CONCURRENCY = 4
1107
+ const KEEP_RUNS = 200
1108
+
1109
+ function normSlotValue(v) {
1110
+ if (Array.isArray(v)) return v.filter((x) => typeof x === 'string' && x.trim() !== '')
1111
+ return typeof v === 'string' && v.trim() !== '' ? [v] : undefined
1112
+ }
1113
+
1114
+ function slotsNorm(slots) {
1115
+ const out = {}
1116
+ for (const k of SLOT_KEYS) {
1117
+ const v = normSlotValue(slots ? slots[k] : undefined)
1118
+ if (v !== undefined && v.length !== 0) out[k] = v
1119
+ }
1120
+ return out
1121
+ }
1122
+
1123
+ function budgetsNorm(budgets) {
1124
+ const out = {}
1125
+ for (const k of BUDGET_KEYS) out[k] = clampRange(budgets ? budgets[k] : '', BUDGET_MIN, BUDGET_MAX)
1126
+ return out
1127
+ }
1128
+
1129
+ // 工作位绑定恒为数组形态(单绑定与数组无区别,引擎候选依次轮换)
1130
+ function SlotRow({ info, value, onChange }) {
1131
+ const items = Array.isArray(value) ? value : (typeof value === 'string' && value !== '' ? [value] : [])
1132
+ return h('div', { className: 'rsww-card' },
1133
+ h('div', { className: 'rsww-row' },
1134
+ h('span', { className: 'rsww-text', style: { fontWeight: 600 } }, info.name)),
1135
+ h('span', { className: 'rsww-note' }, info.desc),
1136
+ h(StrRows, { items, onChange, ph: 'provider/model 或模型名,留空=缺省链', min: 1 }))
1137
+ }
1138
+
1139
+ function BudgetsForm({ budgets, onChange }) {
1140
+ return h('div', { className: 'rsww-section' },
1141
+ h('span', { className: 'rsww-label' }, '预算(clamp [1,10],失焦钳制)'),
1142
+ BUDGET_INFO.map((b) => h('div', { className: 'rsww-kv__row', key: b.key },
1143
+ h('span', { className: 'rsww-label', style: { width: 110, flex: 'none' } }, b.name),
1144
+ h('input', { className: 'rsww-input', type: 'number', value: budgets[b.key], min: BUDGET_MIN, max: BUDGET_MAX,
1145
+ onChange: (e) => onChange({ ...budgets, [b.key]: e.target.value === '' ? '' : Number(e.target.value) }),
1146
+ onBlur: () => onChange({ ...budgets, [b.key]: clampRange(budgets[b.key], BUDGET_MIN, BUDGET_MAX) }) }),
1147
+ h('span', { className: 'rsww-note', style: { flex: 1, minWidth: 200 } }, b.desc))))
1148
+ }
1149
+
1150
+ function ConfigPage() {
1151
+ const [meta, setMeta] = useState(null)
1152
+ const [draft, setDraft] = useState(null)
1153
+ const [error, setError] = useState('')
1154
+ const [saving, setSaving] = useState(false)
1155
+ const [saved, setSaved] = useState(false)
1156
+ const load = useCallback(async () => {
1157
+ const o = await request('config')
1158
+ if (!o.ok) { setError(o.error); return }
1159
+ const snap = { slots: { ...(o.data.config.slots || {}) }, budgets: { ...(o.data.config.budgets || {}) } }
1160
+ setMeta(snap)
1161
+ setDraft({ slots: { ...snap.slots }, budgets: { ...snap.budgets } })
1162
+ setError('')
1163
+ }, [])
1164
+ useEffect(() => { load() }, [load])
1165
+ if (!meta) {
1166
+ return h('div', { className: 'rsww-stack' },
1167
+ h('span', { className: 'rsww-error' }, error || '加载中...'),
1168
+ error ? h('span', { className: 'rsww-note' }, '配置读取失败:宿主半区可能未装载。') : null,
1169
+ h('button', { className: 'rsww-btn', type: 'button', onClick: load }, '重试'))
1170
+ }
1171
+ const dirty = JSON.stringify(slotsNorm(draft.slots)) !== JSON.stringify(slotsNorm(meta.slots))
1172
+ || JSON.stringify(budgetsNorm(draft.budgets)) !== JSON.stringify(budgetsNorm(meta.budgets))
1173
+ const save = async () => {
1174
+ setSaving(true); setError(''); setSaved(false)
1175
+ const o = await post('config-save', { slots: slotsNorm(draft.slots), budgets: budgetsNorm(draft.budgets) })
1176
+ setSaving(false)
1177
+ if (!o.ok) { setError(o.error); return }
1178
+ setSaved(true)
1179
+ await load()
1180
+ }
1181
+ return h('div', { className: 'rsww-stack' },
1182
+ h('div', { className: 'rsww-section' },
1183
+ h('span', { className: 'rsww-label' }, '工作位(留空 = host 缺省解析)'),
1184
+ SLOT_INFO.map((info) => h(SlotRow, {
1185
+ key: info.key, info, value: draft.slots[info.key],
1186
+ onChange: (v) => setDraft((d) => ({ ...d, slots: { ...d.slots, [info.key]: v } })),
1187
+ }))),
1188
+ h(BudgetsForm, { budgets: draft.budgets, onChange: (b) => setDraft((d) => ({ ...d, budgets: b })) }),
1189
+ h('div', { className: 'rsww-section' },
1190
+ h('span', { className: 'rsww-label' }, '常量(不开放配置)'),
1191
+ h('span', { className: 'rsww-note' }, '默认并发 ' + DEFAULT_CONCURRENCY + ' · 运行记录保留 ' + KEEP_RUNS + ' 条')),
1192
+ h('div', { className: 'rsww-toolbar' },
1193
+ dirty ? h(Badge, { tone: 'warn' }, '有未保存修改') : h(Badge, { tone: 'mute' }, '与宿主一致'),
1194
+ h('button', { className: 'rsww-btn', type: 'button', onClick: save, disabled: !dirty || saving }, saving ? '保存中' : '保存'),
1195
+ saved ? h('span', { className: 'rsww-note' }, '已保存并复核') : null,
1196
+ error ? h('span', { className: 'rsww-error' }, error) : null))
1197
+ }
1198
+
1199
+ // ── section 壳:标题/说明/子页 pill(模板默认;运行记录唯一视图=会话页签) ──
1200
+ function RswwApp() {
1201
+ const [page, setPage] = useState('templates')
1202
+ const pages = [{ key: 'templates', label: '流程模板' }, { key: 'config', label: '配置' }]
1203
+ return h('div', { className: 'rsww-root' },
1204
+ React.createElement('style', { 'data-plugin': '@mzzsfy/dsh-rs-workflow', dangerouslySetInnerHTML: { __html: CSS } }),
1205
+ React.createElement('div', { className: 'rsww-head' },
1206
+ React.createElement('span', { className: 'rsww-head__title' }, '若水工作流'),
1207
+ React.createElement('span', { className: 'rsww-head__caption' },
1208
+ '强规则编排:模式内消息被接管,批次推进,结构化产出强制校验;流程模板与配置在此集中管理,运行记录在会话「若水编排」页签查看。')),
1209
+ h(PillGroup, { tab: true, ariaLabel: '子页', value: page, options: pages, onChange: setPage }),
1210
+ page === 'templates' ? h(TemplatesPage) : h(ConfigPage))
1211
+ }
1212
+
1213
+ return {
1214
+ inject: ['slots', 'sessions'],
1215
+ apply(ctx) {
1216
+ ctx.slots.inject('settings.section', () =>
1217
+ ctx.slots.register(
1218
+ { name: 'settings.section', id: 'rs-workflow-board', order: 45, label: '若水工作流' },
1219
+ () => React.createElement(RswwApp),
1220
+ ))
1221
+ // 会话感知条件注入:当前会话存在 rs 运行记录才挂页签(v3 ensure/judge 模式)
1222
+ ctx.effect(() => {
1223
+ let disposed = false
1224
+ let timer = null
1225
+ let disposeView = null
1226
+ let cache = { sessionId: undefined, isRs: false }
1227
+ let currentSessionId
1228
+ const ensure = (isRs) => {
1229
+ if (disposed) return
1230
+ if (isRs && !disposeView) {
1231
+ disposeView = ctx.slots.inject('conversation.view', () =>
1232
+ ctx.slots.register(
1233
+ { name: 'conversation.view', id: VIEW_ID, order: 15, label: '若水编排' },
1234
+ (props) => React.createElement(FlowView, { getSessionId: () => currentSessionId, ...props }),
1235
+ ))
1236
+ } else if (!isRs && disposeView) {
1237
+ disposeView(); disposeView = null
1238
+ }
1239
+ }
1240
+ const judge = async () => {
1241
+ const sessionId = ctx.sessions?.list?.getSnapshot?.().current
1242
+ currentSessionId = sessionId
1243
+ if (sessionId === undefined) { ensure(false); return }
1244
+ if (cache.sessionId === sessionId && cache.fetched) { ensure(cache.isRs); return }
1245
+ try {
1246
+ const res = await fetch(API + 'runs')
1247
+ if (!res.ok) return
1248
+ const json = await res.json()
1249
+ if (disposed) return
1250
+ const isRs = (json && Array.isArray(json.runs) ? json.runs : []).some((r) => r.sessionId === sessionId)
1251
+ cache = { sessionId, isRs, fetched: true }
1252
+ ensure(isRs)
1253
+ } catch { /* host 未就绪:下轮重试 */ }
1254
+ }
1255
+ const unsubscribe = ctx.sessions.list.subscribe(() => { cache = { sessionId: undefined, isRs: false }; judge() })
1256
+ timer = setInterval(judge, REFRESH_MS)
1257
+ judge()
1258
+ return () => {
1259
+ disposed = true
1260
+ unsubscribe()
1261
+ if (timer) clearInterval(timer)
1262
+ if (disposeView) disposeView()
1263
+ if (disposeChip) disposeChip()
1264
+ }
1265
+ }, 'rs-workflow: session-aware flow view')
1266
+ },
1267
+ }
1268
+ },
1269
+ })