agentworkshop 0.7.3 → 0.7.5

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.
Files changed (130) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/B9Yie5HN.js +2 -0
  3. package/.output/public/_nuxt/CN7Zh8GQ.js +1 -0
  4. package/.output/public/_nuxt/CdSgfcYR.js +1 -0
  5. package/.output/public/_nuxt/CdfAP_IH2.js +1 -0
  6. package/.output/public/_nuxt/ChnfyQHz.js +1 -0
  7. package/.output/public/_nuxt/{BkKYf8xY.js → CyygA0q4.js} +4 -4
  8. package/.output/public/_nuxt/{Bz40vyZS.js → DFnGFEav.js} +7 -7
  9. package/.output/public/_nuxt/Xaf_HIR3.js +1 -0
  10. package/.output/public/_nuxt/ZugRk0CM2.js +4 -0
  11. package/.output/public/_nuxt/builds/latest.json +1 -1
  12. package/.output/public/_nuxt/builds/meta/55c10854-ee0e-4be4-924b-a1b0e100fe11.json +1 -0
  13. package/.output/public/_nuxt/{default.iGJy9dP-.css → default.D17kXlD6.css} +1 -1
  14. package/.output/public/_nuxt/monitor.CmwpEUCI.css +1 -0
  15. package/.output/public/_nuxt/yIKY_V1P.js +1 -0
  16. package/.output/server/chunks/_/nitro.mjs +1131 -920
  17. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  18. package/.output/server/chunks/build/{TownView-BkHxMiwZ.mjs → TownView-Bl8Gkcxi.mjs} +3 -3
  19. package/.output/server/chunks/build/{TownView-BkHxMiwZ.mjs.map → TownView-Bl8Gkcxi.mjs.map} +1 -1
  20. package/.output/server/chunks/build/{_id_-Bna1_JuB.mjs → _id_-CWLFGZjV.mjs} +2 -2
  21. package/.output/server/chunks/build/{_id_-Bna1_JuB.mjs.map → _id_-CWLFGZjV.mjs.map} +1 -1
  22. package/.output/server/chunks/build/_id_-GnRJPykX.mjs +1 -1
  23. package/.output/server/chunks/build/{_wsId_-CzLDGT_V.mjs → _wsId_-BhQMjyHG.mjs} +3 -4
  24. package/.output/server/chunks/build/{_wsId_-CzLDGT_V.mjs.map → _wsId_-BhQMjyHG.mjs.map} +1 -1
  25. package/.output/server/chunks/build/{daq-CmeM4u5u.mjs → daq-BOgpn9sl.mjs} +4 -5
  26. package/.output/server/chunks/build/daq-BOgpn9sl.mjs.map +1 -0
  27. package/.output/server/chunks/build/dcw-BTM5YU-B.mjs +1 -1
  28. package/.output/server/chunks/build/{default-Dt6xLdpd.mjs → default-CKJWhkot.mjs} +60 -17
  29. package/.output/server/chunks/build/{default-Dt6xLdpd.mjs.map → default-CKJWhkot.mjs.map} +1 -1
  30. package/.output/server/chunks/build/{logs-CUQt1z7D.mjs → logs-Dyk0I2I9.mjs} +3 -4
  31. package/.output/server/chunks/build/logs-Dyk0I2I9.mjs.map +1 -0
  32. package/.output/server/chunks/build/{monitor-yNoV98rf.mjs → monitor-KW8WLOJd.mjs} +34 -33
  33. package/.output/server/chunks/build/{monitor-yNoV98rf.mjs.map → monitor-KW8WLOJd.mjs.map} +1 -1
  34. package/.output/server/chunks/build/{town-BN4vQPb8.mjs → town-_4tEEEVW.mjs} +3 -4
  35. package/.output/server/chunks/build/town-_4tEEEVW.mjs.map +1 -0
  36. package/.output/server/chunks/build/useDaqStream-hwkhJHFU.mjs +1 -1
  37. package/.output/server/chunks/build/useDcwStream-Bdew_yrr.mjs +1 -1
  38. package/.output/server/chunks/build/{useWorkshopWs-xcdeiSmJ.mjs → useWorkshopWs-B3j5wIAK.mjs} +177 -3
  39. package/.output/server/chunks/build/useWorkshopWs-B3j5wIAK.mjs.map +1 -0
  40. package/.output/server/chunks/build/{workshop-CLhwe5eo.mjs → workshop-k0XZTFKg.mjs} +2 -3
  41. package/.output/server/chunks/build/workshop-k0XZTFKg.mjs.map +1 -0
  42. package/.output/server/chunks/routes/api/workshop/channel-templates/_id/instantiate.post.mjs +1 -1
  43. package/.output/server/chunks/routes/api/workshop/channels.post.mjs +1 -1
  44. package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs +32 -0
  45. package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs.map +1 -0
  46. package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs +56 -0
  47. package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs.map +1 -0
  48. package/.output/server/chunks/routes/api/workshop/plugins/_name/disable.post.mjs +1 -1
  49. package/.output/server/chunks/routes/api/workshop/plugins/_name/enable.post.mjs +1 -1
  50. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.delete.mjs +1 -1
  51. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.put.mjs +1 -1
  52. package/.output/server/chunks/routes/api/workshop/workspaces/_id/channel-templates/_tplId_.post.mjs +1 -1
  53. package/.output/server/chunks/routes/renderer.mjs +2 -2
  54. package/.output/server/chunks/virtual/entry.mjs +17 -11
  55. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  56. package/.output/server/package.json +1 -1
  57. package/README.md +435 -434
  58. package/app/components/AppHeader.vue +138 -0
  59. package/app/composables/workshop/useWorkshopWs.ts +4 -0
  60. package/app/pages/monitor.vue +17 -1
  61. package/app/stores/workshop/hitl.ts +80 -0
  62. package/bin/aw.mjs +0 -0
  63. package/cli/commands/stop.mjs +108 -0
  64. package/cli/commands/tui.mjs +19 -0
  65. package/config.yml +134 -134
  66. package/docs/tui.md +43 -0
  67. package/i18n/locales/en.ts +3 -0
  68. package/i18n/locales/zh-CN.ts +3 -0
  69. package/package.json +140 -144
  70. package/scripts/_spike-pitui.mjs +116 -0
  71. package/scripts/e2e-aw-tui-global.mjs +277 -0
  72. package/scripts/e2e-hitl-live.mjs +323 -0
  73. package/scripts/e2e-line-live.mjs +144 -0
  74. package/scripts/test-hitl-registry.ts +112 -0
  75. package/scripts/test-tui-commands.mjs +148 -0
  76. package/scripts/test-tui-reducers.mjs +72 -0
  77. package/scripts/tsconfig.json +14 -0
  78. package/scripts/tui-smoke.mjs +100 -0
  79. package/server/api/workshop/hitl/pending.get.ts +28 -0
  80. package/server/api/workshop/hitl/respond.post.ts +67 -0
  81. package/server/api/workshop/ws.ts +30 -1
  82. package/server/data/daqs.json +39 -17
  83. package/server/data/dcw-lines.json +14 -0
  84. package/server/data/dcw-products.json +35 -0
  85. package/server/data/dcw-recipes.json +140 -0
  86. package/server/data/dcw-rollback.json +111 -1
  87. package/server/data/dcw-runs.json +135 -90
  88. package/server/data/dcw-writes.json +120 -0
  89. package/server/data/dcws.json +400 -275
  90. package/server/data/line-runs.json +11 -1
  91. package/server/plugins/workshop.ts +7 -0
  92. package/server/services/workshop/agents/harness-terminal.ts +92 -25
  93. package/server/services/workshop/agents/hitl-registry.ts +181 -0
  94. package/server/services/workshop/agents/tool-approvals.ts +15 -0
  95. package/server/services/workshop/scene-events.ts +19 -9
  96. package/shared/workshop-protocol.ts +37 -0
  97. package/tui/README.md +74 -0
  98. package/tui/aw-tui.mjs +406 -0
  99. package/tui/commands/index.mjs +287 -0
  100. package/tui/components/chat-log.mjs +42 -0
  101. package/tui/components/hitl-card.mjs +36 -0
  102. package/tui/components/monitor-pane.mjs +39 -0
  103. package/tui/components/root.mjs +53 -0
  104. package/tui/components/status-bar.mjs +33 -0
  105. package/tui/lib/api.mjs +82 -0
  106. package/tui/lib/config.mjs +58 -0
  107. package/tui/lib/pickers.mjs +102 -0
  108. package/tui/lib/reducers.mjs +218 -0
  109. package/tui/lib/state.mjs +86 -0
  110. package/tui/lib/virtual-terminal.mjs +48 -0
  111. package/tui/lib/ws.mjs +124 -0
  112. package/tui/theme.mjs +38 -0
  113. package/.output/public/_nuxt/B7V9IfTL.js +0 -1
  114. package/.output/public/_nuxt/BE5QyG5J2.js +0 -1
  115. package/.output/public/_nuxt/BuKI8z-5.js +0 -1
  116. package/.output/public/_nuxt/D0C5Q0di.js +0 -1
  117. package/.output/public/_nuxt/DC-QMEk1.js +0 -2
  118. package/.output/public/_nuxt/DEkQ0qQ02.js +0 -1
  119. package/.output/public/_nuxt/builds/meta/c6c01cdd-762e-4fb8-86e7-753c5185b856.json +0 -1
  120. package/.output/public/_nuxt/dt2tKE95.js +0 -4
  121. package/.output/public/_nuxt/i_MKObHo.js +0 -1
  122. package/.output/public/_nuxt/monitor.41fCD6TB.css +0 -1
  123. package/.output/public/_nuxt/tpDMVY5w.js +0 -1
  124. package/.output/server/chunks/build/connection-Dc0fn6yd.mjs +0 -120
  125. package/.output/server/chunks/build/connection-Dc0fn6yd.mjs.map +0 -1
  126. package/.output/server/chunks/build/daq-CmeM4u5u.mjs.map +0 -1
  127. package/.output/server/chunks/build/logs-CUQt1z7D.mjs.map +0 -1
  128. package/.output/server/chunks/build/town-BN4vQPb8.mjs.map +0 -1
  129. package/.output/server/chunks/build/useWorkshopWs-xcdeiSmJ.mjs.map +0 -1
  130. package/.output/server/chunks/build/workshop-CLhwe5eo.mjs.map +0 -1
@@ -0,0 +1,148 @@
1
+ /**
2
+ * TUI 命令系统单元测试(node 直跑):
3
+ * node scripts/test-tui-commands.mjs
4
+ *
5
+ * 覆盖:/命令解析(参数/--flag)/未知命令兜底/分发路由/mock ctx 下的命令执行。
6
+ */
7
+ import { parseCommand, dispatchCommand } from '../tui/commands/index.mjs'
8
+
9
+ let failures = 0
10
+ const check = (name, cond, extra = '') => {
11
+ console.log(`${cond ? ' ✓' : ' ✗ FAIL'} ${name}${extra ? ` (${extra})` : ''}`)
12
+ if (!cond) failures++
13
+ }
14
+
15
+ // ── 解析 ──
16
+ console.log('[1] parseCommand')
17
+ const p1 = parseCommand('/channel new demo --desc 测试频道 --lead 调度长')
18
+ check('命令名解析', p1?.name === 'channel')
19
+ check('位置参数', JSON.stringify(p1.args) === JSON.stringify(['new', 'demo']))
20
+ check('--flag 带值', p1.flags.desc === '测试频道' && p1.flags.lead === '调度长')
21
+ const p2 = parseCommand('/task 调高设定值 --mode loop')
22
+ check('中文标题 + 尾随 flag', p2.args.join(' ') === '调高设定值' && p2.flags.mode === 'loop')
23
+ const p3 = parseCommand('普通文本')
24
+ check('非 / 前缀返回 null', p3 === null)
25
+ const p4 = parseCommand('/hitl')
26
+ check('裸命令', p4.name === 'hitl' && p4.args.length === 0)
27
+ const p5 = parseCommand('/channel add 巡检员 --role lead --bool')
28
+ check('--bool 无值 flag', p5.flags.role === 'lead' && p5.flags.bool === true)
29
+
30
+ // ── 分发(mock ctx) ──
31
+ console.log('[2] dispatchCommand')
32
+ function mockCtx() {
33
+ const calls = []
34
+ const state = {
35
+ channels: [{ id: 'ch-1', name: 'prod线' }],
36
+ activeChannelId: 'ch-1',
37
+ agents: [{ id: 'agt-1', name: '调度长', role: 'lead', harness: 'omp', enabled: 1 }],
38
+ agentStates: {},
39
+ tasks: [],
40
+ hitl: [],
41
+ hitlAnswering: null,
42
+ target: null,
43
+ log: [],
44
+ userName: '张伟',
45
+ }
46
+ return {
47
+ state,
48
+ calls,
49
+ echo: (kind, text) => calls.push({ kind, text }),
50
+ api: {
51
+ listChannels: async () => state.channels,
52
+ createChannel: async body => ({ channelId: 'ch-new', leadAgentId: null, name: body.name }),
53
+ listAgents: async () => state.agents,
54
+ listTemplates: async () => [{ id: 'tpl-1', name: '巡检员' }],
55
+ addAgent: async (_id, body) => ({ id: 'inst-1', name: body.agentId ? '巡检员' : body.name, role: body.role ?? 'worker' }),
56
+ sendMessage: async (_id, body) => calls.push({ send: body }),
57
+ submitTask: async (_id, body) => {
58
+ calls.push({ task: body })
59
+ return { id: 'tk-9' }
60
+ },
61
+ listTasks: async () => [],
62
+ hitlPending: async () => [],
63
+ hitlRespond: async body => calls.push({ respond: body }),
64
+ },
65
+ actions: {
66
+ switchChannel: async (id) => {
67
+ state.activeChannelId = id
68
+ calls.push({ switched: id })
69
+ },
70
+ refreshAgents: async () => {},
71
+ openMonitor: agent => calls.push({ monitor: agent.name }),
72
+ closeMonitor: () => calls.push({ monitorOff: true }),
73
+ submitHitlAnswer: async t => calls.push({ hitlAnswer: t }),
74
+ quit: () => calls.push({ quit: true }),
75
+ },
76
+ }
77
+ }
78
+
79
+ let ctx = mockCtx()
80
+ check('未知命令被兜底', await dispatchCommand(ctx, '/nope') === true && ctx.calls[0].kind === 'error')
81
+ check('非命令放行', await dispatchCommand(ctx, '普通任务文本') === false)
82
+
83
+ ctx = mockCtx()
84
+ await dispatchCommand(ctx, '/channel new smoke线 --lead 调度长')
85
+ check('创建频道并自动切换', ctx.state.activeChannelId === 'ch-new' && ctx.calls.some(c => c.switched === 'ch-new'))
86
+ check('lead 内联定义传递', ctx.state.channels.some(c => c.id === 'ch-new'))
87
+
88
+ ctx = mockCtx()
89
+ await dispatchCommand(ctx, '/channel use prod线')
90
+ check('/channel use 按名切换', ctx.state.activeChannelId === 'ch-1')
91
+ await dispatchCommand(ctx, '/channel use 1')
92
+ check('/channel use 按序号切换', ctx.state.activeChannelId === 'ch-1')
93
+
94
+ ctx = mockCtx()
95
+ await dispatchCommand(ctx, '/channel add 巡检员 --role lead')
96
+ check('模板克隆放置(传 agentId)', ctx.calls.some(() => true) && ctx.state.agents.length === 1)
97
+
98
+ ctx = mockCtx()
99
+ await dispatchCommand(ctx, '/send 调度长 请确认产线状态')
100
+ const sent = ctx.calls.find(c => c.send)
101
+ check('/send immediate 直发+requireReply', sent?.send?.priority === 'immediate' && sent?.send?.toAgentId === 'agt-1')
102
+
103
+ ctx = mockCtx()
104
+ await dispatchCommand(ctx, '/send 不存在的人 你好')
105
+ check('/send 未知成员报错', ctx.calls.some(c => c.kind === 'error'))
106
+
107
+ ctx = mockCtx()
108
+ await dispatchCommand(ctx, '/task 调高温度到182 --mode loop --assignee 调度长')
109
+ const task = ctx.calls.find(c => c.task)
110
+ check('/task 标题+mode+assignee', task?.task?.title === '调高温度到182' && task?.task?.mode === 'loop' && task?.task?.assigneeId === 'agt-1')
111
+
112
+ ctx = mockCtx()
113
+ await dispatchCommand(ctx, '/monitor 调度长')
114
+ check('/monitor 打开面板', ctx.calls.some(c => c.monitor === '调度长'))
115
+ await dispatchCommand(ctx, '/monitor off')
116
+ check('/monitor off 关闭', ctx.calls.some(c => c.monitorOff))
117
+
118
+ ctx = mockCtx()
119
+ await dispatchCommand(ctx, '/hitl 1')
120
+ check('/hitl 序号无效报错', ctx.calls.some(c => c.kind === 'error'))
121
+
122
+ ctx = mockCtx()
123
+ ctx.state.hitl = [{ kind: 'omp-dialog', id: 'd1', method: 'confirm', title: '确认?', agentName: '调度长' }]
124
+ await dispatchCommand(ctx, '/hitl 1')
125
+ check('/hitl 进入作答模式', ctx.state.hitlAnswering?.id === 'd1')
126
+
127
+ ctx = mockCtx()
128
+ await dispatchCommand(ctx, '/msg 心跳检查,请确认')
129
+ const msg = ctx.calls.find(c => c.send)
130
+ check('/msg 缺省路由 lead(immediate)', msg?.send?.toAgentId === 'agt-1' && msg?.send?.priority === 'immediate')
131
+
132
+ ctx = mockCtx()
133
+ await dispatchCommand(ctx, '/msg 调度长 收到请回答')
134
+ const msg2 = ctx.calls.find(c => c.send)
135
+ check('/msg 指定成员', msg2?.send?.toAgentId === 'agt-1')
136
+
137
+ ctx = mockCtx()
138
+ ctx.state.target = { agentId: 'agt-1', name: '调度长' }
139
+ await dispatchCommand(ctx, '/task 检查设备')
140
+ const tsk = ctx.calls.find(c => c.task)
141
+ check('/task 目标成员自动成为 assignee', tsk?.task?.assigneeId === 'agt-1')
142
+
143
+ ctx = mockCtx()
144
+ await dispatchCommand(ctx, '/quit')
145
+ check('/quit 触发退出', ctx.calls.some(c => c.quit))
146
+
147
+ console.log(failures === 0 ? '\n[commands] 全部通过' : `\n[commands] ${failures} 项失败`)
148
+ process.exit(failures === 0 ? 0 : 1)
@@ -0,0 +1,72 @@
1
+ /**
2
+ * TUI 归约器单元测试(node 直跑,无服务依赖):
3
+ * node scripts/test-tui-reducers.mjs
4
+ *
5
+ * 覆盖 AEP 帧 → 会话状态:delta 聚合定稿去重/消息归属/task 状态/hitl 增删/
6
+ * 跨频道标记/终端帧归约(monitor 行)。
7
+ */
8
+ import { createState, withLog } from '../tui/lib/state.mjs'
9
+ import { reduceEnvelope, reduceTermFrame } from '../tui/lib/reducers.mjs'
10
+
11
+ let failures = 0
12
+ const check = (name, cond, extra = '') => {
13
+ console.log(`${cond ? ' ✓' : ' ✗ FAIL'} ${name}${extra ? ` (${extra})` : ''}`)
14
+ if (!cond) failures++
15
+ }
16
+
17
+ function fresh() {
18
+ const store = createState()
19
+ const state = withLog(store.state)
20
+ state.agents = [{ id: 'agt-1', name: '调度长', role: 'lead', harness: 'omp', enabled: 1 }]
21
+ return { store, state }
22
+ }
23
+
24
+ const env = (type, payload, extra = {}) => ({ v: 1, type, seq: 1, at: '', channelId: 'ch-1', ...extra, payload })
25
+
26
+ // ── agent.delta 聚合 + agent.message 定稿 ──
27
+ console.log('[1] 流式聚合')
28
+ let { state } = fresh()
29
+ reduceEnvelope(state, env('agent.delta', { delta: '你' }, { agentId: 'agt-1', taskId: 't1' }))
30
+ reduceEnvelope(state, env('agent.delta', { delta: '好' }, { agentId: 'agt-1', taskId: 't1' }))
31
+ check('delta 聚合进同一流式块', state.log.filter(r => r.kind === 'stream').length === 1 && state.log.at(-1).text === '你好')
32
+ reduceEnvelope(state, env('agent.delta', { delta: '!' }, { agentId: 'agt-2', taskId: 't1' }))
33
+ check('不同 agent 不粘连(独立块)', state.log.filter(r => r.kind === 'stream').length === 2)
34
+ reduceEnvelope(state, env('agent.message', { parts: [{ text: '你好,完整消息' }], metadata: {} }, { agentId: 'agt-1', taskId: 't1' }))
35
+ check('message 定稿升级为 agent 行', state.log.filter(r => r.kind === 'agent').length === 1 && state.log.filter(r => r.kind === 'stream').length === 1)
36
+ check('定稿文本为全文', state.log.find(r => r.kind === 'agent' && r.agentId === 'agt-1').text === '你好,完整消息')
37
+
38
+ // ── a2a.message:人类回显/跨频道 ──
39
+ console.log('[2] 消息投递')
40
+ ;({ state } = fresh())
41
+ reduceEnvelope(state, env('a2a.message', { parts: [{ text: '帮我查产线状态' }], metadata: { 'x-aw-from-label': '张伟' } }))
42
+ check('人类消息渲染 user 行', state.log.at(-1).kind === 'user' && state.log.at(-1).text.includes('产线状态'))
43
+ reduceEnvelope(state, env('a2a.message', { parts: [{ text: '外部指令' }], metadata: { 'x-aw-cross-channel': 'true', 'x-aw-from-label': '母舰' } }))
44
+ check('跨频道消息带标记', state.log.at(-1).text.startsWith('[跨频道]'))
45
+
46
+ // ── task/hitl ──
47
+ console.log('[3] 任务与 HITL')
48
+ ;({ state } = fresh())
49
+ reduceEnvelope(state, env('task.status', { taskId: 'tk-1', state: 'WORKING', title: '调参', progress: 30 }))
50
+ check('task.status 入列表且渲染任务行', state.tasks.length === 1 && state.log.at(-1).kind === 'task')
51
+ reduceEnvelope(state, env('task.progress', { taskId: 'tk-1', progress: 80 }))
52
+ check('task.progress 更新同条', state.tasks[0].progress === 80)
53
+ reduceEnvelope(state, env('hitl.request', { kind: 'omp-dialog', id: 'd1', channelId: 'ch-1', agentId: 'agt-1', agentName: '调度长', method: 'confirm', title: '确认下发?' }))
54
+ check('hitl.request 入待办并渲染提醒行', state.hitl.length === 1 && state.log.at(-1).kind === 'hitl')
55
+ reduceEnvelope(state, env('hitl.request', { kind: 'omp-dialog', id: 'd1', channelId: 'ch-1', agentId: 'agt-1', agentName: '调度长', method: 'confirm', title: '确认下发?(更新)' }))
56
+ check('hitl 幂等 upsert', state.hitl.length === 1 && state.hitl[0].title.includes('更新'))
57
+ reduceEnvelope(state, env('hitl.resolved', { kind: 'omp-dialog', id: 'd1', channelId: 'ch-1', agentId: 'agt-1', outcome: 'answered' }))
58
+ check('hitl.resolved 清空待办', state.hitl.length === 0 && state.log.at(-1).kind === 'hitl-resolved')
59
+
60
+ // ── 终端帧归约 ──
61
+ console.log('[4] 终端帧(monitor 行)')
62
+ const r1 = reduceTermFrame({ type: 'tool_execution_start', toolName: 'dcw_control', args: { node_id: 'n1', value: 182 } })
63
+ check('工具开始帧 → info 行', r1.lines.length === 1 && r1.lines[0].tone === 'info')
64
+ const r2 = reduceTermFrame({ type: 'extension_ui_request', method: 'select', title: '选择方案' })
65
+ check('HITL 帧 → warn 行', r2.lines[0].tone === 'warn' && r2.lines[0].text.includes('/hitl'))
66
+ const r3 = reduceTermFrame({ type: 'extension_ui_request', method: 'cancel', targetId: 'x' })
67
+ check('cancel 帧不产生行', r3.lines.length === 0)
68
+ const r4 = reduceTermFrame({ type: '__terminal_notice', level: 'warning', message: '观看者离开' })
69
+ check('notice 帧 → warn 行', r4.lines[0].tone === 'warn')
70
+
71
+ console.log(failures === 0 ? '\n[reducers] 全部通过' : `\n[reducers] ${failures} 项失败`)
72
+ process.exit(failures === 0 ? 0 : 1)
@@ -0,0 +1,14 @@
1
+ {
2
+ // tsx 直跑 scripts/*.ts 的解析配置(nuxt 根 tsconfig 是 references 组合,无 paths)。
3
+ // 用法:npx tsx --tsconfig scripts/tsconfig.json scripts/<test>.ts
4
+ "compilerOptions": {
5
+ "target": "ESNext",
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "allowImportingTsExtensions": true,
9
+ "baseUrl": "..",
10
+ "paths": {
11
+ "@/*": ["*"]
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * TUI 无头 e2e(虚拟终端驱动真实 TUI ↔ 真实 dev server):
3
+ * node scripts/tui-smoke.mjs [--base http://127.0.0.1:3000]
4
+ *
5
+ * 覆盖:认证引导(--token)→ 频道自动接入 → /channels → /channel new(创建+切换)
6
+ * → /agents → 普通文本发送(服务端回显) → /hitl → 清理 → /quit。
7
+ * 建的 tui-smoke-* 频道在退出前清理(?purge=1 级联删除)。
8
+ */
9
+ import { createApi } from '../tui/lib/api.mjs'
10
+
11
+ const base = (() => {
12
+ const i = process.argv.indexOf('--base')
13
+ return i > 0 ? process.argv[i + 1] : 'http://127.0.0.1:3000'
14
+ })()
15
+
16
+ // 种子 admin(dev 环境默认);生产勿用
17
+ const api = createApi({ baseUrl: base })
18
+ const login = await api.login(process.env.AW_TUI_EMAIL ?? 'zhangwei@awshop.io', process.env.AW_TUI_PASSWORD ?? 'Awshop@123')
19
+ const token = login.token
20
+
21
+ let failures = 0
22
+ const check = (name, cond) => {
23
+ console.log(`${cond ? ' ✓' : ' ✗ FAIL'} ${name}`)
24
+ if (!cond) failures++
25
+ }
26
+
27
+ // ── 启动 TUI(headless;--token 免交互;--channel 空 = 自动选第一个频道) ──
28
+ const { main } = await import('../tui/aw-tui.mjs')
29
+ await main(['--headless', '--url', base, '--token', token, '--channel', ''])
30
+ const vt = globalThis.__tuiSmokeTerminal
31
+ check('虚拟终端句柄就绪', Boolean(vt))
32
+
33
+ const waitText = async (needle, timeoutMs = 8000) => {
34
+ const t0 = Date.now()
35
+ while (Date.now() - t0 < timeoutMs) {
36
+ if (vt.text().includes(needle)) return true
37
+ await new Promise(r => setTimeout(r, 100))
38
+ }
39
+ return false
40
+ }
41
+ const type = async (text) => {
42
+ for (const ch of text) vt.emitInput(ch)
43
+ await new Promise(r => setTimeout(r, 40))
44
+ vt.emitInput('\r')
45
+ await new Promise(r => setTimeout(r, 500))
46
+ }
47
+
48
+ check('TUI 启动就绪横幅', await waitText('TUI 已就绪'))
49
+ // 启动频道选择器(新交互):有频道 → Esc 自动进第一个;空账户 → 提示创建
50
+ if (await waitText('选择要进入的频道', 8000)) {
51
+ vt.emitInput('\x1b')
52
+ check('启动自动接入首个频道', await waitText('已切换到频道「', 12_000))
53
+ }
54
+ else {
55
+ check('空账户提示创建频道', await waitText('尚无频道', 12_000))
56
+ }
57
+
58
+ await type('/channels')
59
+ check('/channels 列出频道(计数行)', await waitText('个:'))
60
+
61
+ const name = `tui-smoke-${Date.now().toString(36)}`
62
+ // 带 --lead 创建:普通文本缺省路由 lead(messages 端点要求 toAgentId)
63
+ await type(`/channel new ${name} --lead smoke-调度长 smoke测试频道`)
64
+ check('/channel new 创建回执', await waitText('✔ 频道已创建'))
65
+ check('/channel new 自动切换', await waitText(`已切换到频道「${name}」`))
66
+
67
+ await type('/agents')
68
+ check('/agents 成员面渲染(lead 成员行)', await waitText('smoke-调度长(lead)'))
69
+
70
+ await type('你好,这是一条 smoke 测试任务')
71
+ check('普通文本 = 发布正式任务(回执)', await waitText('✔ 任务已发布', 12_000))
72
+
73
+ await type('/hitl')
74
+ check('/hitl 无待办提示', await waitText('没有待人工处理'))
75
+
76
+ // 监控面板:开启后右侧渲染面板头;omp 未 spawn 时还会出现等待提示
77
+ // (真实 lead 可能已被上面的消息触发 spawn,故只断言确定事实)
78
+ await type('/monitor smoke-调度长')
79
+ check('/monitor 面板开启回执', await waitText('监控已开启:smoke-调度长'))
80
+ check('/monitor 右侧面板渲染', await waitText('┌─ 监控 smoke-调度长', 12_000))
81
+ await type('/monitor off')
82
+ check('/monitor off 关闭回执', await waitText('监控面板已关闭'))
83
+
84
+ // 清理 smoke 频道与 lead 模板(先于 /quit —— /quit 会 process.exit)
85
+ try {
86
+ const ch = (await api.listChannels()).find(c => c.name === name)
87
+ if (ch) await api.request(`/api/workshop/channels/${ch.id}?purge=1`, { method: 'DELETE' })
88
+ const tpl = (await api.listTemplates()).find(t => t.name === 'smoke-调度长')
89
+ if (tpl) await api.request(`/api/workshop/agents/${tpl.id}`, { method: 'DELETE' })
90
+ }
91
+ catch { /* 清理失败不判失败 */ }
92
+
93
+ // 退出码必须反映断言结果:失败时直接非零退出(不走 /quit 的 process.exit(0))
94
+ const ok = failures === 0
95
+ console.log(ok ? '\n[tui-smoke] 全部通过,/quit 退出' : `\n[tui-smoke] ${failures} 项失败,直接退出`)
96
+ if (!ok) process.exit(1)
97
+ await type('/quit')
98
+ // /quit 内部 process.exit(0);若未退出(异常)则在此兜底收口
99
+ console.log('[tui-smoke] /quit 未按预期退出进程(异常),兜底退出')
100
+ process.exit(0)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GET /api/workshop/hitl/pending?channelId= —— 全局待人工处理快照。
3
+ *
4
+ * 统一视图:omp ask 对话框(hitl-registry 的 omp-dialog)+ dcw 工具审批
5
+ * (dcw-approval)。WebUI 全局徽标与 TUI /hitl 命令共用;实时增量走 AEP
6
+ * hitl.request/hitl.resolved 帧,本端点为快照对齐/恢复入口。
7
+ *
8
+ * 鉴权:用户 token;按 channel 所有权过滤(listChannelsForUser 同口径),
9
+ * channelId 查询参数等价于单频道过滤。
10
+ */
11
+ import { getQuery } from 'h3'
12
+ import { resolveUser } from '@/server/api/workshop/caller'
13
+ import { defineApiHandler } from '@/server/utils/response'
14
+ import { getWorkshopManager } from '@/server/plugins/workshop'
15
+ import { getHitlRegistry } from '@/server/services/workshop/agents/hitl-registry'
16
+
17
+ export default defineApiHandler(async (event) => {
18
+ const user = resolveUser(event)
19
+ const q = getQuery(event)
20
+ const channelId = typeof q.channelId === 'string' ? q.channelId : ''
21
+
22
+ const manager = getWorkshopManager()
23
+ const visible = new Set(manager.listChannelsForUser(user.id).map(c => c.id))
24
+ const items = getHitlRegistry()
25
+ .snapshot(channelId || undefined)
26
+ .filter(i => visible.has(i.channelId))
27
+ return { items }
28
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * POST /api/workshop/hitl/respond —— 统一 HITL 应答路由(WebUI/TUI 共用)。
3
+ *
4
+ * body: { kind: 'omp-dialog' | 'dcw-approval', id, value?, confirmed?, cancelled?, comment? }
5
+ * - omp-dialog → respondTerminalUi(pid, …):extension_ui_response 直写 omp stdin
6
+ * - dcw-approval → toolApprovals.decide(id, approved=confirmed===true, comment)(audit 留痕)
7
+ *
8
+ * 幂等:待办不在登记处(已被他端处理/超时/撤销)→ 409 ALREADY_RESOLVED。
9
+ * 鉴权:用户 token + channel 所有权(与 pending.get 同口径)。
10
+ */
11
+ import { readBody } from 'h3'
12
+ import { resolveUser } from '@/server/api/workshop/caller'
13
+ import { defineApiHandler } from '@/server/utils/response'
14
+ import { AppError } from '@/server/utils/errors'
15
+ import { getWorkshopManager } from '@/server/plugins/workshop'
16
+ import { getHitlRegistry } from '@/server/services/workshop/agents/hitl-registry'
17
+ import { getToolApprovals } from '@/server/services/workshop/agents/tool-approvals'
18
+ import { respondTerminalUi } from '@/server/services/workshop/agents/harness-terminal'
19
+ import { audit } from '@/server/services/workshop/ops/ops'
20
+
21
+ interface RespondBody {
22
+ kind?: string
23
+ id?: string
24
+ value?: string
25
+ confirmed?: boolean
26
+ cancelled?: boolean
27
+ comment?: string
28
+ }
29
+
30
+ export default defineApiHandler(async (event) => {
31
+ const user = resolveUser(event)
32
+ const body = await readBody<RespondBody>(event) ?? {}
33
+ const kind = body.kind
34
+ const id = String(body.id ?? '')
35
+ if ((kind !== 'omp-dialog' && kind !== 'dcw-approval') || !id) {
36
+ throw new AppError(400, 'BAD_REQUEST', 'body 需要 { kind: omp-dialog|dcw-approval, id, value?/confirmed?/cancelled?/comment? }')
37
+ }
38
+
39
+ // 所有权校验:待办必须属于请求者可见的 channel
40
+ const item = getHitlRegistry().find(kind, id)
41
+ if (!item) throw new AppError(409, 'ALREADY_RESOLVED', `待办已处理或不存在: ${id}`)
42
+ const manager = getWorkshopManager()
43
+ manager.getChannelForUser(item.channelId, user.id)
44
+
45
+ if (kind === 'omp-dialog') {
46
+ if (!item.pid) throw new AppError(410, 'SESSION_GONE', '对话框缺少 pid(进程已退出?)')
47
+ try {
48
+ respondTerminalUi(item.pid, {
49
+ id,
50
+ value: typeof body.value === 'string' ? body.value : undefined,
51
+ confirmed: typeof body.confirmed === 'boolean' ? body.confirmed : undefined,
52
+ cancelled: body.cancelled === true,
53
+ })
54
+ }
55
+ catch (err) {
56
+ throw new AppError(410, 'SESSION_GONE', `omp 会话不可用: ${err instanceof Error ? err.message : String(err)}`)
57
+ }
58
+ audit({ actor: user.id, actorName: user.name, actorKind: 'user', action: 'hitl.respond', targetKind: 'omp-dialog', targetId: id, detail: { channelId: item.channelId, agentId: item.agentId, cancelled: body.cancelled === true } })
59
+ return { ok: true, kind, id }
60
+ }
61
+
62
+ // dcw-approval:approved = confirmed === true(缺省视为拒绝,与 decide 端点同语义)
63
+ const approved = body.confirmed === true
64
+ const approval = getToolApprovals().decide(id, approved, String(body.comment ?? ''), user.id, user.name)
65
+ audit({ actor: user.id, actorName: user.name, actorKind: 'user', action: approved ? 'approval.approve' : 'approval.reject', targetKind: 'tool-approval', targetId: id, detail: { comment: String(body.comment ?? ''), via: 'hitl.respond' } })
66
+ return { ok: true, kind, id, approval }
67
+ })
@@ -24,7 +24,8 @@ import type { AgentEvent } from '../../services/workshop/agents/agent-interface'
24
24
  import type { A2AMessage } from '../../services/workshop/types/a2a'
25
25
  import type { AepEnvelope } from '../../../shared/workshop-protocol'
26
26
  import { parseJson } from '../../services/workshop/db/database'
27
- import { registerScenePeer, unregisterScenePeer } from '../../services/workshop/scene-events'
27
+ import { registerScenePeer, unregisterScenePeer, broadcastPeerEvent } from '../../services/workshop/scene-events'
28
+ import { subscribeHitlEvents } from '../../services/workshop/agents/hitl-registry'
28
29
  import { createLogger } from '../../services/workshop/logger'
29
30
  import { retentionSettings } from '../../services/workshop/settings'
30
31
 
@@ -56,6 +57,12 @@ interface ChannelStream {
56
57
  ringBytes: number
57
58
  peers: Set<WsPeer>
58
59
  unsubs: Array<() => void>
60
+ /**
61
+ * HITL 待办事件订阅(hitl-registry 模块级总线 → 频道流)。
62
+ * 单独存放、不进 unsubs:rebindStreamIfStale 会整体清空 unsubs 重订总线,
63
+ * 而 registry 订阅与总线生命周期无关,只在建流/回收时挂接一次。
64
+ */
65
+ hitlUnsub: (() => void) | null
59
66
  /** 当前已订阅的 channel 总线对象(总线生命期短于 stream:空闲卸载会重建 bus → 需重订) */
60
67
  busRef: object | null
61
68
  /**
@@ -399,6 +406,17 @@ function bindStreamSubscriptions(manager: AgentChannelManager, stream: ChannelSt
399
406
  }))
400
407
  }
401
408
 
409
+ /** HITL 待办事件挂接(建流时一次;closeStream 回收;与总线 rebind 解耦,见 ChannelStream.hitlUnsub)。
410
+ * 双水路:频道流 publish(seq/环形缓冲/落库,时间线可回放)+ 全员直推
411
+ * (channelId='',WebUI 全局徽标/TUI 状态条不依赖 channel 订阅即达;快照恢复走 REST)。 */
412
+ function bindHitlSubscription(manager: AgentChannelManager, stream: ChannelStream): void {
413
+ stream.hitlUnsub?.()
414
+ stream.hitlUnsub = subscribeHitlEvents(stream.channelId, (e) => {
415
+ publish(manager, stream, e.type, e.payload, { agentId: e.agentId })
416
+ broadcastPeerEvent(e.type, e.payload)
417
+ })
418
+ }
419
+
402
420
  /** 自愈:stream 已订阅的总线 ≠ 管理器当前总线(空闲卸载销毁/重建)或 manager 更替 → 重订 */
403
421
  function rebindStreamIfStale(manager: AgentChannelManager, stream: ChannelStream): void {
404
422
  const currentBus = internalsOf(manager).buses.get(stream.channelId) ?? null
@@ -480,6 +498,13 @@ function closeStream(manager: AgentChannelManager, channelId: string): void {
480
498
  }
481
499
  catch { /* 尽力清理 */ }
482
500
  }
501
+ if (stream.hitlUnsub) {
502
+ try {
503
+ stream.hitlUnsub()
504
+ }
505
+ catch { /* 尽力清理 */ }
506
+ stream.hitlUnsub = null
507
+ }
483
508
  streams.delete(channelId)
484
509
  for (const peer of stream.peers) {
485
510
  peerChannels.get(peer)?.delete(channelId)
@@ -500,6 +525,8 @@ export function ensureStream(manager: AgentChannelManager, channelId: string): C
500
525
  if (existing) {
501
526
  // 订阅入口处即时自愈(不等 sweep):bus 已重建 → 立即重订到当前总线
502
527
  rebindStreamIfStale(manager, existing)
528
+ // manager 更替(HMR)时 hitl 订阅闭包持旧 manager → 幂等重挂(bind 内先退订)
529
+ bindHitlSubscription(manager, existing)
503
530
  return existing
504
531
  }
505
532
  // seq 从持久层续接(重启后继续递增;INSERT OR IGNORE 幂等兜底)
@@ -511,11 +538,13 @@ export function ensureStream(manager: AgentChannelManager, channelId: string): C
511
538
  ringBytes: 0,
512
539
  peers: new Set(),
513
540
  unsubs: [],
541
+ hitlUnsub: null,
514
542
  busRef: internalsOf(manager).buses.get(channelId) ?? null,
515
543
  dbBuffer: [],
516
544
  dbFlushTimer: null,
517
545
  }
518
546
  bindStreamSubscriptions(manager, stream)
547
+ bindHitlSubscription(manager, stream)
519
548
  // bind 过程可能懒创建 bus:同步真实 busRef,否则 stale 检查会误判重绑
520
549
  stream.busRef = internalsOf(manager).buses.get(channelId) ?? null
521
550
  streams.set(channelId, stream)
@@ -64,9 +64,9 @@
64
64
  "posX": 1695,
65
65
  "posZ": 1135,
66
66
  "lineId": "ln-af002514",
67
- "value": 167.3,
67
+ "value": 168.3,
68
68
  "state": "ok",
69
- "lastAt": "2026-09-03T17:12:29.342Z",
69
+ "lastAt": "2026-09-04T05:12:13.223Z",
70
70
  "createdAt": "2026-08-27T12:39:08.110Z"
71
71
  },
72
72
  {
@@ -308,9 +308,9 @@
308
308
  "deviceBindingId": null,
309
309
  "driverConfig": {},
310
310
  "lineId": "ln-af002514",
311
- "value": 319,
311
+ "value": 316,
312
312
  "state": "ok",
313
- "lastAt": "2026-09-03T17:12:29.342Z",
313
+ "lastAt": "2026-09-04T05:12:13.223Z",
314
314
  "createdAt": "2026-08-28T09:37:38.518Z"
315
315
  },
316
316
  {
@@ -330,9 +330,9 @@
330
330
  "deviceBindingId": null,
331
331
  "driverConfig": {},
332
332
  "lineId": "ln-af002514",
333
- "value": 167.5,
333
+ "value": 168.2,
334
334
  "state": "ok",
335
- "lastAt": "2026-09-03T17:12:29.342Z",
335
+ "lastAt": "2026-09-04T05:12:13.223Z",
336
336
  "createdAt": "2026-08-29T03:30:57.463Z"
337
337
  },
338
338
  {
@@ -1012,9 +1012,9 @@
1012
1012
  "posX": 2395,
1013
1013
  "posZ": 1135,
1014
1014
  "lineId": "ln-af002514",
1015
- "value": 168.9,
1015
+ "value": 167.4,
1016
1016
  "state": "ok",
1017
- "lastAt": "2026-09-03T17:12:29.855Z",
1017
+ "lastAt": "2026-09-04T05:12:12.962Z",
1018
1018
  "createdAt": "2026-08-31T04:07:19.038Z"
1019
1019
  },
1020
1020
  {
@@ -3026,9 +3026,9 @@
3026
3026
  "posX": 120,
3027
3027
  "posZ": 120,
3028
3028
  "lineId": "ln-af002514",
3029
- "value": 0.9894,
3030
- "state": "alarm",
3031
- "lastAt": "2026-09-03T17:12:29.342Z",
3029
+ "value": 0.9663,
3030
+ "state": "ok",
3031
+ "lastAt": "2026-09-04T05:12:13.223Z",
3032
3032
  "createdAt": "2026-09-03T10:17:59.182Z"
3033
3033
  },
3034
3034
  {
@@ -3050,9 +3050,9 @@
3050
3050
  "posX": 120,
3051
3051
  "posZ": 120,
3052
3052
  "lineId": "ln-af002514",
3053
- "value": 0.9544,
3054
- "state": "alarm",
3055
- "lastAt": "2026-09-03T17:12:29.342Z",
3053
+ "value": 0.9841,
3054
+ "state": "ok",
3055
+ "lastAt": "2026-09-04T05:12:13.223Z",
3056
3056
  "createdAt": "2026-09-03T11:18:01.401Z"
3057
3057
  },
3058
3058
  {
@@ -3072,9 +3072,9 @@
3072
3072
  "deviceBindingId": "dev-mthzr991-ujuad",
3073
3073
  "driverConfig": {},
3074
3074
  "lineId": "ln-af002514",
3075
- "value": 0.9924,
3076
- "state": "alarm",
3077
- "lastAt": "2026-09-03T17:12:29.342Z",
3075
+ "value": 0.9652,
3076
+ "state": "ok",
3077
+ "lastAt": "2026-09-04T05:12:13.223Z",
3078
3078
  "createdAt": "2026-09-03T11:34:59.332Z"
3079
3079
  },
3080
3080
  {
@@ -3196,5 +3196,27 @@
3196
3196
  "state": "offline",
3197
3197
  "lastAt": null,
3198
3198
  "createdAt": "2026-09-03T17:12:52.201Z"
3199
+ },
3200
+ {
3201
+ "id": "dn-2c41fbba",
3202
+ "templateRef": "daq-temp-tc",
3203
+ "name": "探针温度",
3204
+ "driver": "mock",
3205
+ "enabled": true,
3206
+ "intervalMs": 500,
3207
+ "publishIntervalMs": null,
3208
+ "unit": "℃",
3209
+ "decimals": 1,
3210
+ "min": 150,
3211
+ "max": 185,
3212
+ "warnLow": 152.8,
3213
+ "warnHigh": 182.2,
3214
+ "deviceBindingId": null,
3215
+ "driverConfig": {},
3216
+ "lineId": "ln-1b318320",
3217
+ "value": 168.5,
3218
+ "state": "ok",
3219
+ "lastAt": "2026-09-04T05:12:14.009Z",
3220
+ "createdAt": "2026-09-04T05:08:10.069Z"
3199
3221
  }
3200
3222
  ]
@@ -1083,5 +1083,19 @@
1083
1083
  "color": "#b58cff",
1084
1084
  "description": "",
1085
1085
  "createdAt": "2026-09-03T17:12:52.197Z"
1086
+ },
1087
+ {
1088
+ "id": "ln-b44bea4f",
1089
+ "name": "探针线",
1090
+ "color": "#ff8a5c",
1091
+ "description": "",
1092
+ "createdAt": "2026-09-04T05:05:33.805Z"
1093
+ },
1094
+ {
1095
+ "id": "ln-1b318320",
1096
+ "name": "探针线1788498490044",
1097
+ "color": "#3aa0ff",
1098
+ "description": "",
1099
+ "createdAt": "2026-09-04T05:08:10.054Z"
1086
1100
  }
1087
1101
  ]