agentworkshop 0.7.4 → 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.
- package/cli/commands/stop.mjs +108 -0
- package/config.yml +1 -1
- package/package.json +1 -1
- package/scripts/e2e-aw-tui-global.mjs +277 -0
- package/scripts/test-tui-commands.mjs +17 -0
- package/scripts/tui-smoke.mjs +13 -4
- package/tui/README.md +11 -2
- package/tui/aw-tui.mjs +64 -14
- package/tui/commands/index.mjs +43 -6
- package/tui/components/status-bar.mjs +4 -0
- package/tui/lib/pickers.mjs +102 -0
- package/tui/lib/state.mjs +6 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 指令:stop —— 终止当前配置根下运行中的 aw 实例(读单实例锁 → 终止进程树)。
|
|
3
|
+
// 用法:aw stop [--home] (--home 强制以 home 配置根为目标;默认随 cwd 解析)
|
|
4
|
+
// ============================================================
|
|
5
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { color } from '../core/logger.mjs'
|
|
9
|
+
|
|
10
|
+
export const meta = {
|
|
11
|
+
name: 'stop',
|
|
12
|
+
aliases: [],
|
|
13
|
+
group: '服务',
|
|
14
|
+
summary: '终止运行中的 aw 服务实例(读单实例锁)',
|
|
15
|
+
usage: 'aw stop [--home]',
|
|
16
|
+
needsProject: false,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pidAlive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return err?.code === 'EPERM'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 终止进程树:Windows taskkill /T /F;POSIX 先 SIGTERM 宽限 5s 再 SIGKILL */
|
|
30
|
+
function killTree(pid) {
|
|
31
|
+
if (process.platform === 'win32') {
|
|
32
|
+
const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8' })
|
|
33
|
+
if (r.status !== 0) throw new Error((r.stderr || r.stdout || 'taskkill 失败').trim().split(/\r?\n/)[0])
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 'SIGTERM')
|
|
38
|
+
}
|
|
39
|
+
catch { /* 已死 */ }
|
|
40
|
+
const deadline = Date.now() + 5000
|
|
41
|
+
while (Date.now() < deadline && pidAlive(pid)) {
|
|
42
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
|
|
43
|
+
}
|
|
44
|
+
if (pidAlive(pid)) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 'SIGKILL')
|
|
47
|
+
}
|
|
48
|
+
catch { /* 已死 */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function run(argv, ctx) {
|
|
53
|
+
// argv 为 parseArgs 结果({ flags, positionals, unknown });兼容裸数组调用
|
|
54
|
+
const flags = argv?.flags ?? {}
|
|
55
|
+
const positionals = Array.isArray(argv?.positionals) ? argv.positionals : (Array.isArray(argv) ? argv : [])
|
|
56
|
+
const forceHome = flags.home === true || positionals.includes('--home')
|
|
57
|
+
|
|
58
|
+
const configRoot = forceHome
|
|
59
|
+
? ctx.home
|
|
60
|
+
: (ctx.mode === 'repo' ? join(ctx.root, '.AgentWorkShop') : ctx.home)
|
|
61
|
+
const lockPath = join(configRoot, '.runtime', 'aw.lock')
|
|
62
|
+
|
|
63
|
+
if (!existsSync(lockPath)) {
|
|
64
|
+
console.log(`${color.blue('ℹ')} 未发现运行中的实例(无锁文件:${lockPath})`)
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
let holder
|
|
68
|
+
try {
|
|
69
|
+
holder = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
rmSync(lockPath, { force: true })
|
|
73
|
+
console.log(`${color.blue('ℹ')} 锁文件损坏,已清除:${lockPath}`)
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const pid = Number(holder?.pid)
|
|
78
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
79
|
+
rmSync(lockPath, { force: true })
|
|
80
|
+
console.log(`${color.blue('ℹ')} 锁内 PID 无效,已清除锁文件`)
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
if (!pidAlive(pid)) {
|
|
84
|
+
rmSync(lockPath, { force: true })
|
|
85
|
+
console.log(`${color.blue('ℹ')} 持锁进程已不存在(pid=${pid},残留锁),已清除锁文件`)
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(`› 终止实例 pid=${pid}(mode=${holder.mode ?? '?'}${holder.port ? `,端口=${holder.port}` : ''},启动于 ${holder.startedAt ?? '?'})`)
|
|
90
|
+
try {
|
|
91
|
+
killTree(pid)
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error(`${color.red('✖')} 终止失败:${err.message}`)
|
|
95
|
+
console.error(` 可手动执行:taskkill /PID ${pid} /T /F(Windows)`)
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
// 锁文件由退出钩子自清;残留则手动兜底
|
|
99
|
+
for (let i = 0; i < 20 && existsSync(lockPath); i++) {
|
|
100
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(lockPath)) rmSync(lockPath, { force: true })
|
|
104
|
+
}
|
|
105
|
+
catch { /* ignore */ }
|
|
106
|
+
console.log(`${color.green('✔')} 实例已停止(pid=${pid})`)
|
|
107
|
+
return 0
|
|
108
|
+
}
|
package/config.yml
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局 `aw tui` 完整 E2E(用户真实路径:npm i -g agentworkshop → aw tui):
|
|
3
|
+
* node scripts/e2e-aw-tui-global.mjs [--base http://127.0.0.1:3000]
|
|
4
|
+
*
|
|
5
|
+
* 前置:全局已安装 agentworkshop@0.7.4(npm root -g 可解析);dev/prod 服务运行。
|
|
6
|
+
* 场景:加载【全局包】的 tui 入口(非仓库源码)→ 建频道(内联 omp lead)→
|
|
7
|
+
* 成员列表 → 任务下发(触发 omp spawn)→ /monitor 独立监控接入 →
|
|
8
|
+
* /send 注入 ask → /hitl 定位作答 → REST 确认落定 → 终端帧证实答案
|
|
9
|
+
* 回传 agent → /task 正式任务 + /tasks 列表 → /monitor off → 清理。
|
|
10
|
+
*/
|
|
11
|
+
import { execSync } from 'node:child_process'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
const BASE = (() => {
|
|
17
|
+
const i = process.argv.indexOf('--base')
|
|
18
|
+
return i > 0 ? process.argv[i + 1] : 'http://127.0.0.1:3000'
|
|
19
|
+
})()
|
|
20
|
+
// --repo:加载仓库源码的 tui(开发验证新交互);默认加载全局安装包(用户真实路径)
|
|
21
|
+
const USE_REPO = process.argv.includes('--repo')
|
|
22
|
+
const WS_BASE = BASE.replace(/^http/, 'ws')
|
|
23
|
+
const TAG = Date.now().toString(36)
|
|
24
|
+
const ASK_INSTRUCTION = 'Use the ask tool NOW to ask me: Deploy? with options go and abort. After I answer, call complete_task with the deliverable set to my answer.'
|
|
25
|
+
|
|
26
|
+
let failures = 0
|
|
27
|
+
let passed = 0
|
|
28
|
+
const check = (name, ok, detail = '') => {
|
|
29
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
30
|
+
if (ok) passed += 1
|
|
31
|
+
else failures += 1
|
|
32
|
+
}
|
|
33
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
34
|
+
|
|
35
|
+
async function waitUntil(name, cond, timeoutMs = 300_000, intervalMs = 1000) {
|
|
36
|
+
const deadline = Date.now() + timeoutMs
|
|
37
|
+
let last = null
|
|
38
|
+
while (Date.now() < deadline) {
|
|
39
|
+
try {
|
|
40
|
+
last = await cond()
|
|
41
|
+
if (last) return last
|
|
42
|
+
}
|
|
43
|
+
catch (e) { last = e }
|
|
44
|
+
await sleep(intervalMs)
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`waitUntil timeout: ${name} (last=${String(last).slice(0, 200)})`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const api = async (method, path, { body, token } = {}) => {
|
|
50
|
+
const headers = { 'content-type': 'application/json' }
|
|
51
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
52
|
+
const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined })
|
|
53
|
+
return { status: res.status, ...(await res.json().catch(() => ({}))) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 终端 WS(答案回传验证用) */
|
|
57
|
+
function openTerm(agentId, channelId, token) {
|
|
58
|
+
const messages = []
|
|
59
|
+
const ws = new WebSocket(`${WS_BASE}/api/system/monitor/terminal/ws?agentId=${agentId}&channelId=${channelId}&token=${encodeURIComponent(token)}`)
|
|
60
|
+
ws.addEventListener('message', (ev) => {
|
|
61
|
+
try {
|
|
62
|
+
messages.push(JSON.parse(ev.data))
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
})
|
|
66
|
+
return { ws, messages, frames: () => messages.filter(m => m.type === 'term.frames').flatMap(m => m.frames) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 解析 TUI 入口:--repo = 仓库源码;默认 = 全局安装包(用户 `aw tui` 的真实加载路径) */
|
|
70
|
+
function tuiEntry() {
|
|
71
|
+
if (USE_REPO) {
|
|
72
|
+
const entry = join(process.cwd(), 'tui', 'aw-tui.mjs')
|
|
73
|
+
return { entry, ok: existsSync(entry), repo: true }
|
|
74
|
+
}
|
|
75
|
+
const root = execSync('npm root -g', { encoding: 'utf8' }).trim()
|
|
76
|
+
const entry = join(root, 'agentworkshop', 'tui', 'aw-tui.mjs')
|
|
77
|
+
return { entry, ok: existsSync(entry), repo: false }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
console.log(`\n━━━ 全局 aw tui 完整 E2E @ ${BASE} ━━━`)
|
|
82
|
+
|
|
83
|
+
// 0. 入口存在(全局包 = 0.7.4 发布包含 tui/;repo = 源码)
|
|
84
|
+
const g = tuiEntry()
|
|
85
|
+
check(`0.1 TUI 入口存在(${g.repo ? '仓库源码' : '全局包'})`, g.ok, g.entry)
|
|
86
|
+
if (!g.ok) process.exit(1)
|
|
87
|
+
|
|
88
|
+
// 登录
|
|
89
|
+
const reg = await api('POST', '/api/users/register', {
|
|
90
|
+
body: { email: `awtui-${TAG}@test.local`, password: 'Passw0rd!123', name: `awtui-${TAG}` },
|
|
91
|
+
})
|
|
92
|
+
const token = reg.data?.token
|
|
93
|
+
if (!token) throw new Error(`注册失败: ${JSON.stringify(reg).slice(0, 160)}`)
|
|
94
|
+
|
|
95
|
+
// 0.2 预建一个频道(启动频道选择器需要至少一个可选项;全局包模式同样受益)
|
|
96
|
+
const bootCh = (await api('POST', '/api/workshop/channels', {
|
|
97
|
+
body: { name: `awtui-boot-${TAG}`, description: '启动选择器测试频道' },
|
|
98
|
+
token,
|
|
99
|
+
})).data
|
|
100
|
+
check('0.2 预建启动频道', Boolean(bootCh?.channelId), `id=${bootCh?.channelId?.slice(0, 8)}`)
|
|
101
|
+
|
|
102
|
+
// 1. 加载 TUI,headless 驱动
|
|
103
|
+
const mod = await import(pathToFileURL(g.entry).href)
|
|
104
|
+
await mod.main(['--headless', '--url', BASE, '--token', token, '--channel', ''])
|
|
105
|
+
const vt = globalThis.__tuiSmokeTerminal
|
|
106
|
+
const type = async (text) => {
|
|
107
|
+
for (const c of text) vt.emitInput(c)
|
|
108
|
+
await sleep(50)
|
|
109
|
+
vt.emitInput('\r')
|
|
110
|
+
await sleep(400)
|
|
111
|
+
}
|
|
112
|
+
const waitTui = async (needle, timeoutMs = 300_000) => {
|
|
113
|
+
const deadline = Date.now() + timeoutMs
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
if (vt.text().includes(needle)) return true
|
|
116
|
+
await sleep(500)
|
|
117
|
+
}
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
check('1.1 TUI 启动就绪', await waitTui('TUI 已就绪', 30_000), g.entry.slice(-60))
|
|
121
|
+
|
|
122
|
+
// 1.2 [新交互] 启动频道选择器:↑↓ + Enter 选择预建频道;Esc 自动进第一个
|
|
123
|
+
if (USE_REPO) {
|
|
124
|
+
check('1.2 启动弹出频道选择器', await waitTui('选择要进入的频道', 20_000))
|
|
125
|
+
vt.emitInput('\x1b[B') // 下移一次,选中第二个(证明方向键导航)
|
|
126
|
+
await sleep(200)
|
|
127
|
+
vt.emitInput('\r')
|
|
128
|
+
check('1.3 Enter 进入所选频道', await waitTui('已切换到频道「', 30_000))
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
check('1.2 (全局包)自动接入频道', await waitTui('已切换到频道「', 30_000))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2. Channel 操作:列表 + 创建(内联 omp lead)+ 自动切换
|
|
135
|
+
await type('/channels')
|
|
136
|
+
check('2.1 /channels 列出频道', await waitTui('个:', 15_000))
|
|
137
|
+
const channelName = `awtui-e2e-${TAG}`
|
|
138
|
+
await type(`/channel new ${channelName} --lead 领航员 全局tui端到端频道`)
|
|
139
|
+
check('2.2 /channel new 创建成功', await waitTui('✔ 频道已创建', 30_000))
|
|
140
|
+
check('2.3 自动切换到新频道', await waitTui(`已切换到频道「${channelName}」`, 30_000))
|
|
141
|
+
|
|
142
|
+
// 2.4 [新交互] /channel use 无参 → 频道选择器 → 方向键导航到目标频道 → Enter
|
|
143
|
+
// (SelectList 的可打印字符过滤不生效,导航用方向键;序号按 REST 顺序计算)
|
|
144
|
+
if (USE_REPO) {
|
|
145
|
+
await type('/channel use')
|
|
146
|
+
check('2.4 /channel use 弹出频道选择器', await waitTui('选择要进入的频道', 20_000))
|
|
147
|
+
const order = (await api('GET', '/api/workshop/channels', { token })).data ?? []
|
|
148
|
+
const downTimes = Math.max(0, order.findIndex(c => c.name === channelName))
|
|
149
|
+
for (let i = 0; i < downTimes; i++) {
|
|
150
|
+
vt.emitInput('\x1b[B')
|
|
151
|
+
await sleep(150)
|
|
152
|
+
}
|
|
153
|
+
await sleep(200)
|
|
154
|
+
vt.emitInput('\r')
|
|
155
|
+
await sleep(800)
|
|
156
|
+
check('2.5 方向键选择后频道正确', await waitTui(`已切换到频道「${channelName}」`, 20_000))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. Agent 操作:成员列表出现内联建的 omp lead(选择器切换后刷新有竞态,轮询重发)
|
|
160
|
+
{
|
|
161
|
+
const ok = await waitUntil('3.1 /agents 出现 lead 成员', async () => {
|
|
162
|
+
await type('/agents')
|
|
163
|
+
return vt.text().includes('领航员(lead)') ? true : null
|
|
164
|
+
}, 30_000).catch(() => null)
|
|
165
|
+
check('3.1 /agents 出现 lead 成员', ok === true)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 4. 任务下发:普通文本 → lead(触发 omp spawn)
|
|
169
|
+
await type('请保持待命,等待操作员通过终端下达进一步指令。')
|
|
170
|
+
console.log(' … 等待 omp spawn(冷启动可达 2-3 分钟)…')
|
|
171
|
+
const ch = (await api('GET', '/api/workshop/channels', { token })).data?.find?.(c => c.name === channelName)
|
|
172
|
+
?? (await api('GET', '/api/workshop/channels', { token })).data?.at(-1)
|
|
173
|
+
const channelId = ch?.id
|
|
174
|
+
const members = channelId ? (await api('GET', `/api/workshop/channels/${channelId}/agents`, { token })).data ?? [] : []
|
|
175
|
+
const lead = members.find(a => a.role === 'lead')
|
|
176
|
+
check('4.1 频道/lead 实例可查(REST 交叉核对)', Boolean(lead?.id), `lead=${lead?.id?.slice(0, 8)}`)
|
|
177
|
+
|
|
178
|
+
const spawnOk = await waitUntil('4.2 omp 进程 spawn', async () => {
|
|
179
|
+
const mon = await api('GET', '/api/system/monitor', { token })
|
|
180
|
+
return (mon.data?.processes ?? []).find(p => p.harness === 'omp' && p.alive && p.agentId === lead?.id) ? true : null
|
|
181
|
+
}, 300_000).catch(() => null)
|
|
182
|
+
check('4.2 omp 进程 spawn(REST 监控)', spawnOk === true)
|
|
183
|
+
|
|
184
|
+
// 5. 独立 monitor:/monitor 领航员 → 终端面板接入
|
|
185
|
+
await type('/monitor 领航员')
|
|
186
|
+
check('5.1 /monitor 面板开启', await waitTui('监控已开启:领航员', 20_000))
|
|
187
|
+
const termConnected = await waitTui('已接入 领航员', 120_000)
|
|
188
|
+
check('5.2 监控面板接入 omp 会话(term.init)', termConnected)
|
|
189
|
+
|
|
190
|
+
// 6. HITL:等首回合结束 → /send 注入 ask → /hitl 定位作答
|
|
191
|
+
const idleOk = await waitUntil('6.1 lead 首回合结束(idle)', async () => {
|
|
192
|
+
const terms = await api('GET', `/api/workshop/channels/${channelId}/terminals`, { token })
|
|
193
|
+
const mine = (terms.data ?? []).find(t2 => t2.agentId === lead?.id)
|
|
194
|
+
return mine && mine.running === false ? true : null
|
|
195
|
+
}, 300_000).catch(() => null)
|
|
196
|
+
check('6.1 lead 空闲(可注入)', idleOk === true)
|
|
197
|
+
await sleep(1500)
|
|
198
|
+
await type(`/send 领航员 ${ASK_INSTRUCTION}`)
|
|
199
|
+
check('6.2 /send 直发回执', await waitTui('✔ 已直发 领航员', 30_000))
|
|
200
|
+
|
|
201
|
+
const item = await waitUntil('6.3 HITL 待办到达(REST Deploy?)', async () => {
|
|
202
|
+
const p = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
|
|
203
|
+
return (p.data?.items ?? []).find(i => String(i.title).includes('Deploy?')) ?? null
|
|
204
|
+
}, 300_000).catch(() => null)
|
|
205
|
+
check('6.3 omp ask → 全局待办(Deploy?)', Boolean(item), item ? `method=${item.method} options=${JSON.stringify(item.options)}` : 'timeout')
|
|
206
|
+
|
|
207
|
+
await type('/hitl')
|
|
208
|
+
check('6.4 /hitl 列表渲染', await waitTui('待人工处理', 20_000))
|
|
209
|
+
const row = await waitUntil('6.5 列表出现 Deploy? 行', () => {
|
|
210
|
+
const m = vt.text().match(/(\d+)\. \[omp-dialog\] Deploy\?/)
|
|
211
|
+
return m ? m[1] : null
|
|
212
|
+
}, 60_000).catch(() => null)
|
|
213
|
+
check('6.5 目标待办行定位', Boolean(row), `序号=${row}`)
|
|
214
|
+
await type(`/hitl ${row}`)
|
|
215
|
+
check('6.6 进入作答卡', await waitTui('HITL 作答', 20_000))
|
|
216
|
+
await type('1')
|
|
217
|
+
check('6.7 应答提交回执(选项 1 = go)', await waitTui('应答已提交', 30_000))
|
|
218
|
+
if (failures > 0) console.log('[diag 6.7 tail]', JSON.stringify(vt.text().slice(-700)))
|
|
219
|
+
|
|
220
|
+
const cleared = await waitUntil('6.8 REST 待办不再含 Deploy?', async () => {
|
|
221
|
+
const p = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
|
|
222
|
+
return ((p.data?.items ?? []).every(i => !String(i.title).includes('Deploy?'))) ? true : null
|
|
223
|
+
}, 60_000).catch(() => null)
|
|
224
|
+
check('6.8 应答写入服务端(待办落定)', cleared === true)
|
|
225
|
+
|
|
226
|
+
// 6.9 答案回传 agent 铁证:ask 工具结果含 go(独立终端 WS 帧)
|
|
227
|
+
const term = openTerm(lead.id, channelId, token)
|
|
228
|
+
const askFed = await waitUntil('6.9 ask 工具收到 go(终端帧)', () => {
|
|
229
|
+
const list = term.frames().filter(f => f.frame.type === 'tool_execution_end' && String(f.frame.toolName) === 'ask')
|
|
230
|
+
const lastAsk = list.at(-1)
|
|
231
|
+
return lastAsk ? String(lastAsk.frame.result ?? '').toLowerCase().includes('go') : null
|
|
232
|
+
}, 120_000).catch(() => null)
|
|
233
|
+
check('6.9 闭环:答案回传 agent(ask 结果含 go)', Boolean(askFed))
|
|
234
|
+
|
|
235
|
+
// 6.10-6.14 [新交互] Tab 目标选择器 + 通信/任务分流(仓库源码特性)
|
|
236
|
+
if (USE_REPO) {
|
|
237
|
+
vt.emitInput('\t')
|
|
238
|
+
check('6.10 Tab 呼出目标选择器', await waitTui('选择对话目标', 20_000))
|
|
239
|
+
vt.emitInput('\x1b[B')
|
|
240
|
+
await sleep(200)
|
|
241
|
+
vt.emitInput('\r')
|
|
242
|
+
check('6.11 选成员 → 通信模式 + 自动开监控', await waitTui('监控已开启:领航员', 20_000) && await waitTui('@领航员(通信)', 20_000))
|
|
243
|
+
await type('你在吗?看到请回应')
|
|
244
|
+
check('6.12 普通文本按通信直发目标', await waitTui('通信消息已发 → @领航员', 30_000))
|
|
245
|
+
vt.emitInput('\t')
|
|
246
|
+
await waitTui('选择对话目标', 20_000)
|
|
247
|
+
vt.emitInput('\r')
|
|
248
|
+
await sleep(400)
|
|
249
|
+
check('6.13 选频道 → 重置任务模式', await waitTui('频道(任务)', 20_000))
|
|
250
|
+
await type('/msg 心跳检查,请确认收到')
|
|
251
|
+
check('6.14 /msg 通信消息(目标重置后路由 lead)', await waitTui('通信消息已发 → @', 30_000))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 7. 正式任务 + 任务列表
|
|
255
|
+
await type('/task 汇报当前待命状态')
|
|
256
|
+
check('7.1 /task 正式任务提交', await waitTui('✔ 任务已发布', 30_000))
|
|
257
|
+
await type('/tasks')
|
|
258
|
+
check('7.2 /tasks 列表出现任务', await waitTui('汇报当前待命状态', 30_000))
|
|
259
|
+
|
|
260
|
+
// 8. monitor 关闭 + 清理
|
|
261
|
+
await type('/monitor off')
|
|
262
|
+
check('8.1 /monitor off 关闭', await waitTui('监控面板已关闭', 20_000))
|
|
263
|
+
await api('DELETE', `/api/workshop/channels/${channelId}?purge=1`, { token }).catch(() => {})
|
|
264
|
+
await api('DELETE', `/api/workshop/channels/${bootCh.channelId}?purge=1`, { token }).catch(() => {})
|
|
265
|
+
|
|
266
|
+
console.log(`\n━━━ 结果: ${passed} passed / ${failures} failed ━━━`)
|
|
267
|
+
console.log('(经 /quit 退出全局 TUI 进程)')
|
|
268
|
+
if (failures > 0) process.exit(1)
|
|
269
|
+
await type('/quit')
|
|
270
|
+
console.log('[warn] /quit 未按预期退出,兜底退出')
|
|
271
|
+
process.exit(0)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
main().catch((err) => {
|
|
275
|
+
console.error('FATAL', err.message)
|
|
276
|
+
process.exit(1)
|
|
277
|
+
})
|
|
@@ -39,6 +39,7 @@ function mockCtx() {
|
|
|
39
39
|
tasks: [],
|
|
40
40
|
hitl: [],
|
|
41
41
|
hitlAnswering: null,
|
|
42
|
+
target: null,
|
|
42
43
|
log: [],
|
|
43
44
|
userName: '张伟',
|
|
44
45
|
}
|
|
@@ -123,6 +124,22 @@ ctx.state.hitl = [{ kind: 'omp-dialog', id: 'd1', method: 'confirm', title: '确
|
|
|
123
124
|
await dispatchCommand(ctx, '/hitl 1')
|
|
124
125
|
check('/hitl 进入作答模式', ctx.state.hitlAnswering?.id === 'd1')
|
|
125
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
|
+
|
|
126
143
|
ctx = mockCtx()
|
|
127
144
|
await dispatchCommand(ctx, '/quit')
|
|
128
145
|
check('/quit 触发退出', ctx.calls.some(c => c.quit))
|
package/scripts/tui-smoke.mjs
CHANGED
|
@@ -46,7 +46,14 @@ const type = async (text) => {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
check('TUI 启动就绪横幅', await waitText('TUI 已就绪'))
|
|
49
|
-
|
|
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
|
+
}
|
|
50
57
|
|
|
51
58
|
await type('/channels')
|
|
52
59
|
check('/channels 列出频道(计数行)', await waitText('个:'))
|
|
@@ -61,7 +68,7 @@ await type('/agents')
|
|
|
61
68
|
check('/agents 成员面渲染(lead 成员行)', await waitText('smoke-调度长(lead)'))
|
|
62
69
|
|
|
63
70
|
await type('你好,这是一条 smoke 测试任务')
|
|
64
|
-
check('
|
|
71
|
+
check('普通文本 = 发布正式任务(回执)', await waitText('✔ 任务已发布', 12_000))
|
|
65
72
|
|
|
66
73
|
await type('/hitl')
|
|
67
74
|
check('/hitl 无待办提示', await waitText('没有待人工处理'))
|
|
@@ -83,9 +90,11 @@ try {
|
|
|
83
90
|
}
|
|
84
91
|
catch { /* 清理失败不判失败 */ }
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
// 退出码必须反映断言结果:失败时直接非零退出(不走 /quit 的 process.exit(0))
|
|
87
94
|
const ok = failures === 0
|
|
95
|
+
console.log(ok ? '\n[tui-smoke] 全部通过,/quit 退出' : `\n[tui-smoke] ${failures} 项失败,直接退出`)
|
|
96
|
+
if (!ok) process.exit(1)
|
|
88
97
|
await type('/quit')
|
|
89
98
|
// /quit 内部 process.exit(0);若未退出(异常)则在此兜底收口
|
|
90
99
|
console.log('[tui-smoke] /quit 未按预期退出进程(异常),兜底退出')
|
|
91
|
-
process.exit(
|
|
100
|
+
process.exit(0)
|
package/tui/README.md
CHANGED
|
@@ -21,10 +21,18 @@ aw tui --url http://127.0.0.1:3000 --token ut-xxxx --channel <名>
|
|
|
21
21
|
┌ 会话时间线(左) ┌ 监控面板(右,/monitor 开启)
|
|
22
22
|
│ ◆ 你 消息 · ◆ Agent 回复 · 任务状态行 │ ┌─ 监控 <agent> · 流式中
|
|
23
23
|
├ HITL 作答卡(/hitl <n> 进入作答时出现)
|
|
24
|
-
├ 状态条:频道 · 成员(忙 n) · HITL 待处理 n · 连接态
|
|
24
|
+
├ 状态条:频道 · 输入目标 · 成员(忙 n) · HITL 待处理 n · 连接态
|
|
25
25
|
└ 输入框(/ 命令补全 · ↑↓ 历史)
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
## 交互
|
|
29
|
+
|
|
30
|
+
- **启动频道选择器**:TUI 就绪后弹出频道列表(↑↓ 移动 · Enter 进入 · Esc 自动进第一个);
|
|
31
|
+
随时可用 `/channel use`(无参)再次呼出。
|
|
32
|
+
- **Tab 选择对话目标**:呼出成员选择器 —— 选「频道(发布任务)」= 普通文本发布正式任务;
|
|
33
|
+
选某成员 = 切换为**通信对话**并自动打开其独立监控面板。状态条实时显示当前输入目标。
|
|
34
|
+
- **输入框语义**:普通文本按目标分流(频道 → 任务 / 成员 → 通信);`/task`、`/msg` 可显式指定。
|
|
35
|
+
|
|
28
36
|
## 命令
|
|
29
37
|
|
|
30
38
|
| 命令 | 说明 |
|
|
@@ -32,9 +40,10 @@ aw tui --url http://127.0.0.1:3000 --token ut-xxxx --channel <名>
|
|
|
32
40
|
| `/help` | 命令列表 |
|
|
33
41
|
| `/channels` | 列出我的频道 |
|
|
34
42
|
| `/channel new <名> [--desc …] [--lead <名>]` | 创建频道(可选内联建 lead) |
|
|
35
|
-
| `/channel use
|
|
43
|
+
| `/channel use [名\|序号]` | 切换频道(无参 = 交互选择器;WS 断点续传) |
|
|
36
44
|
| `/channel add <模板\|名> [--role lead\|worker] [--harness omp] [--config JSON]` | 放置 Agent 实例 |
|
|
37
45
|
| `/agents` | 当前频道成员与实时状态 |
|
|
46
|
+
| `/msg [<agent>] <文本…>` | 发布通信消息(缺省收件人 = 当前目标或 lead) |
|
|
38
47
|
| `/send <agent\|序号> <文本…>` | 向指定 Agent 直发(忙碌时 steer 注入) |
|
|
39
48
|
| `/task <标题…> [--mode goal\|loop\|pipeline] [--assignee <agent>]` | 提交正式任务 |
|
|
40
49
|
| `/tasks` | 任务列表与七态/进度 |
|
package/tui/aw-tui.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { createApi } from './lib/api.mjs'
|
|
|
16
16
|
import { connectAep, connectTerm } from './lib/ws.mjs'
|
|
17
17
|
import { createState, pushLog, withLog } from './lib/state.mjs'
|
|
18
18
|
import { reduceEnvelope, reduceTermFrame } from './lib/reducers.mjs'
|
|
19
|
+
import { openChannelPicker, openTargetPicker } from './lib/pickers.mjs'
|
|
19
20
|
import { dispatchCommand, slashCommandCompletions } from './commands/index.mjs'
|
|
20
21
|
import { ChatLog } from './components/chat-log.mjs'
|
|
21
22
|
import { StatusBar } from './components/status-bar.mjs'
|
|
@@ -244,10 +245,24 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
244
245
|
tui.stop()
|
|
245
246
|
process.exit(0)
|
|
246
247
|
},
|
|
248
|
+
/** 交互式频道选择(启动/`/channel use` 无参) */
|
|
249
|
+
pickChannel() {
|
|
250
|
+
openChannelPicker(tui, state, {
|
|
251
|
+
onSelect: async (id) => {
|
|
252
|
+
tui.setFocus(editor)
|
|
253
|
+
await actions.switchChannel(id)
|
|
254
|
+
},
|
|
255
|
+
onCancel: async () => {
|
|
256
|
+
tui.setFocus(editor)
|
|
257
|
+
// Esc 兜底:未进入任何频道时自动进第一个
|
|
258
|
+
if (!state.activeChannelId && state.channels[0]) await actions.switchChannel(state.channels[0].id)
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
},
|
|
247
262
|
}
|
|
248
263
|
const ctx = { state, store, api, echo, push, actions }
|
|
249
264
|
|
|
250
|
-
// ── 编辑器提交路由:HITL 作答 → 命令 →
|
|
265
|
+
// ── 编辑器提交路由:HITL 作答 → 命令 → 普通文本按目标分流 ──
|
|
251
266
|
editor.onSubmit = async (text) => {
|
|
252
267
|
const trimmed = text.trim()
|
|
253
268
|
editor.setText('')
|
|
@@ -263,25 +278,53 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
263
278
|
return
|
|
264
279
|
}
|
|
265
280
|
if (!state.activeChannelId) {
|
|
266
|
-
push('error', '
|
|
281
|
+
push('error', '未选择频道:Tab 或 /channel use 打开选择器')
|
|
267
282
|
return
|
|
268
283
|
}
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
284
|
+
// 普通文本按目标分流:选定了成员 = 通信消息(immediate);频道 = 发布正式任务
|
|
285
|
+
if (state.target) {
|
|
286
|
+
const to = state.agents.find(a => a.id === state.target.agentId)
|
|
287
|
+
if (!to) {
|
|
288
|
+
state.target = null
|
|
289
|
+
push('error', '对话目标已失效(成员被移除),已重置为频道模式')
|
|
290
|
+
store.notify()
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
await api.sendMessage(state.activeChannelId, { toAgentId: to.id, text: trimmed, priority: 'immediate', requireReply: true, fromLabel: state.userName || '用户' })
|
|
295
|
+
push('system', `✔ 通信消息已发 → @${to.name}`)
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
push('error', `发送失败:${err.message}`)
|
|
299
|
+
}
|
|
300
|
+
store.notify()
|
|
274
301
|
return
|
|
275
302
|
}
|
|
303
|
+
// 频道模式 = 发布正式任务(状态经 /tasks 跟踪)
|
|
276
304
|
try {
|
|
277
|
-
await api.
|
|
305
|
+
const task = await api.submitTask(state.activeChannelId, { title: trimmed, mode: 'goal' })
|
|
306
|
+
push('system', `✔ 任务已发布:${task?.id?.slice(0, 8) ?? ''}「${trimmed}」`)
|
|
278
307
|
}
|
|
279
308
|
catch (err) {
|
|
280
|
-
push('error',
|
|
309
|
+
push('error', `任务发布失败:${err.message}`)
|
|
281
310
|
}
|
|
282
311
|
store.notify()
|
|
283
312
|
}
|
|
284
313
|
|
|
314
|
+
// ── Tab:呼出对话目标选择器(成员 + 频道模式;选定成员自动开独立监控) ──
|
|
315
|
+
tui.addInputListener((data) => {
|
|
316
|
+
if (data !== '\t' || state.targetPickerOpen) return undefined
|
|
317
|
+
openTargetPicker(tui, state, {
|
|
318
|
+
onPick: (agent) => {
|
|
319
|
+
tui.setFocus(editor)
|
|
320
|
+
if (agent) actions.openMonitor(agent)
|
|
321
|
+
store.notify()
|
|
322
|
+
},
|
|
323
|
+
onCancel: () => tui.setFocus(editor),
|
|
324
|
+
})
|
|
325
|
+
return { consume: true }
|
|
326
|
+
})
|
|
327
|
+
|
|
285
328
|
// ── AEP 事件流 ──
|
|
286
329
|
const seqCursor = new Map()
|
|
287
330
|
const aep = connectAep({
|
|
@@ -311,14 +354,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
311
354
|
push('system', `AgentWorkShop TUI 已就绪 ${state.baseUrl}`)
|
|
312
355
|
push('system', '普通文本 = 发任务到当前频道;/help 命令列表;/hitl 处理待办。')
|
|
313
356
|
|
|
314
|
-
// ──
|
|
357
|
+
// ── 启动:显式 --channel 直接进入;否则 TUI 就绪后弹出频道选择器 ──
|
|
358
|
+
let needPick = false
|
|
315
359
|
try {
|
|
316
360
|
const channels = await api.listChannels()
|
|
317
361
|
state.channels = channels
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
362
|
+
if (opts.channel) {
|
|
363
|
+
const target = channels.find(c => c.name === opts.channel || c.id === opts.channel)
|
|
364
|
+
if (target) await actions.switchChannel(target.id)
|
|
365
|
+
else push('warn', `频道不存在:${opts.channel}(/channels 查看)`)
|
|
366
|
+
}
|
|
367
|
+
else if (channels.length > 0) needPick = true
|
|
322
368
|
else push('warn', '尚无频道:/channel new <名称> 创建一个。')
|
|
323
369
|
const me = await api.get('/api/workshop/users/me').catch(() => null)
|
|
324
370
|
state.userName = me?.user?.name ?? me?.name ?? '用户'
|
|
@@ -328,6 +374,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
328
374
|
}
|
|
329
375
|
|
|
330
376
|
tui.start()
|
|
377
|
+
if (needPick) {
|
|
378
|
+
push('system', '↑↓ 选择要进入的频道,Enter 确认(Esc 自动进入第一个)。')
|
|
379
|
+
actions.pickChannel()
|
|
380
|
+
}
|
|
331
381
|
return tui
|
|
332
382
|
}
|
|
333
383
|
|
package/tui/commands/index.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export const commands = [
|
|
|
34
34
|
description: '命令列表',
|
|
35
35
|
handler(ctx) {
|
|
36
36
|
const rows = commands.map(c => ` /${c.name}${c.usage ? ` ${c.usage}` : ''} — ${c.description}`)
|
|
37
|
-
ctx.echo('system', `可用命令:\n${rows.join('\n')}\n
|
|
37
|
+
ctx.echo('system', `可用命令:\n${rows.join('\n')}\n普通文本按当前目标发送:频道 → 发布正式任务;@成员(Tab 选择) → 通信消息。/msg /task 可显式指定。`)
|
|
38
38
|
},
|
|
39
39
|
},
|
|
40
40
|
{
|
|
@@ -51,8 +51,8 @@ export const commands = [
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
name: 'channel',
|
|
54
|
-
usage: 'new <name> [--desc …] [--lead <名>] | use
|
|
55
|
-
description: '
|
|
54
|
+
usage: 'new <name> [--desc …] [--lead <名>] | use [名|序号] | add <模板|名> [--role lead] [--harness omp] [--config JSON]',
|
|
55
|
+
description: '创建/切换频道(use 不带参数 = 交互选择)、向频道放置 Agent',
|
|
56
56
|
async handler(ctx, args, flags) {
|
|
57
57
|
const sub = args.shift()
|
|
58
58
|
if (sub === 'new') {
|
|
@@ -69,7 +69,11 @@ export const commands = [
|
|
|
69
69
|
}
|
|
70
70
|
else if (sub === 'use') {
|
|
71
71
|
const key = args.shift()
|
|
72
|
-
if (!key)
|
|
72
|
+
if (!key) {
|
|
73
|
+
// 交互式选择(浮层)
|
|
74
|
+
ctx.actions.pickChannel?.()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
73
77
|
const ch = resolveChannel(ctx.state, key)
|
|
74
78
|
if (!ch) return ctx.echo('error', `频道不存在:${key}`)
|
|
75
79
|
await ctx.actions.switchChannel(ch.id)
|
|
@@ -126,10 +130,33 @@ export const commands = [
|
|
|
126
130
|
ctx.echo('system', `✔ 已直发 ${agent.name}`)
|
|
127
131
|
},
|
|
128
132
|
},
|
|
133
|
+
{
|
|
134
|
+
name: 'msg',
|
|
135
|
+
usage: '[<agent>] <文本…>',
|
|
136
|
+
description: '发布通信消息(即时送达,忙碌时 steer 注入;缺省收件人 = 当前对话目标或 lead)',
|
|
137
|
+
async handler(ctx, args) {
|
|
138
|
+
let target = ctx.state.target
|
|
139
|
+
const first = args[0]
|
|
140
|
+
if (first && !ctx.state.agents.some(a => a.name === first || a.id === first) && !/^\d+$/.test(first)) {
|
|
141
|
+
// 首参不是成员 → 整段是文本
|
|
142
|
+
}
|
|
143
|
+
else if (first) {
|
|
144
|
+
const key = args.shift()
|
|
145
|
+
target = resolveAgent(ctx.state, key)
|
|
146
|
+
if (!target) return ctx.echo('error', `成员不存在:${key}(/agents 查看)`)
|
|
147
|
+
}
|
|
148
|
+
const text = args.join(' ')
|
|
149
|
+
if (!text) return ctx.echo('error', '用法:/msg [<agent>] <文本…>')
|
|
150
|
+
const to = target ?? leadOf(ctx.state)
|
|
151
|
+
if (!to) return ctx.echo('error', '频道没有 lead,请指定收件人:/msg <agent> <文本…>')
|
|
152
|
+
await ctx.api.sendMessage(ctx.state.activeChannelId, { toAgentId: to.id, text, priority: 'immediate', requireReply: true, fromLabel: ctx.state.userName || '用户' })
|
|
153
|
+
ctx.echo('system', `✔ 通信消息已发 → @${to.name}`)
|
|
154
|
+
},
|
|
155
|
+
},
|
|
129
156
|
{
|
|
130
157
|
name: 'task',
|
|
131
158
|
usage: '<标题…> [--mode goal|loop|pipeline] [--assignee <agent>]',
|
|
132
|
-
description: '
|
|
159
|
+
description: '发布正式任务(缺省路由 lead,状态经 /tasks 跟踪)',
|
|
133
160
|
async handler(ctx, args, flags) {
|
|
134
161
|
const title = args.join(' ')
|
|
135
162
|
if (!title) return ctx.echo('error', '用法:/task <标题…> [--mode goal|loop|pipeline] [--assignee <agent>]')
|
|
@@ -139,8 +166,11 @@ export const commands = [
|
|
|
139
166
|
if (!a) return ctx.echo('error', `assignee 不存在:${flags.assignee}`)
|
|
140
167
|
body.assigneeId = a.id
|
|
141
168
|
}
|
|
169
|
+
else if (ctx.state.target) {
|
|
170
|
+
body.assigneeId = ctx.state.target.agentId
|
|
171
|
+
}
|
|
142
172
|
const task = await ctx.api.submitTask(ctx.state.activeChannelId, body)
|
|
143
|
-
ctx.echo('system', `✔
|
|
173
|
+
ctx.echo('system', `✔ 任务已发布:${task?.id?.slice(0, 8) ?? ''}「${title}」`)
|
|
144
174
|
},
|
|
145
175
|
},
|
|
146
176
|
{
|
|
@@ -215,6 +245,13 @@ function resolveAgent(state, key) {
|
|
|
215
245
|
return state.agents.find(a => a.name === key || a.id === key) ?? (Number.isInteger(Number.parseInt(key, 10)) ? byIdx : undefined)
|
|
216
246
|
}
|
|
217
247
|
|
|
248
|
+
/** 当前对话目标的收件人解析:目标成员 → lead */
|
|
249
|
+
function leadOf(state) {
|
|
250
|
+
const ch = state.channels.find(c => c.id === state.activeChannelId)
|
|
251
|
+
const leadId = ch?.leadAgentId ?? state.agents.find(a => a.role === 'lead')?.id
|
|
252
|
+
return state.agents.find(a => a.id === leadId) ?? null
|
|
253
|
+
}
|
|
254
|
+
|
|
218
255
|
function parseConfigFlag(raw) {
|
|
219
256
|
if (!raw || raw === true) return undefined
|
|
220
257
|
try {
|
|
@@ -16,8 +16,12 @@ export class StatusBar {
|
|
|
16
16
|
const channel = s.channels.find(c => c.id === s.activeChannelId)
|
|
17
17
|
const members = s.agents.length
|
|
18
18
|
const busy = Object.values(s.agentStates).filter(a => a.state === 'busy').length
|
|
19
|
+
const target = s.target
|
|
20
|
+
? theme.info(`@${s.target.name}(通信)`)
|
|
21
|
+
: theme.accent('频道(任务)')
|
|
19
22
|
const parts = [
|
|
20
23
|
theme.accent(`▣ ${channel?.name ?? '未选择频道'}`),
|
|
24
|
+
`${theme.faint('输入→')} ${target}`,
|
|
21
25
|
members > 0 ? theme.faint(`成员 ${members}(忙 ${busy})`) : theme.faint('成员 0'),
|
|
22
26
|
s.hitl.length > 0 ? theme.warn(`⏸ HITL 待处理 ${s.hitl.length}(/hitl)`) : theme.faint('HITL 0'),
|
|
23
27
|
s.monitor.agentId ? theme.info(`监控:${s.monitor.waiting ? '等待进程' : s.monitor.name}`) : null,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// 交互选择器(浮层)—— 频道选择 / Agent 选择。
|
|
3
|
+
// 基于 pi-tui showOverlay + SelectList:方向键移动、Enter 选择、Esc 取消。
|
|
4
|
+
// ============================================================
|
|
5
|
+
import { SelectList } from '@earendil-works/pi-tui'
|
|
6
|
+
import { theme } from '../theme.mjs'
|
|
7
|
+
|
|
8
|
+
/** 组合"标题 + SelectList"的浮层组件。返回 handle { hide, list, component, __bindHandle } */
|
|
9
|
+
function pickerComponent(title, items, maxVisible, onSelect, onCancel) {
|
|
10
|
+
const list = new SelectList(items, maxVisible, theme.editor.selectList)
|
|
11
|
+
const wrap = {
|
|
12
|
+
render(width) {
|
|
13
|
+
return [theme.accent(`┌─ ${title}`), ...list.render(Math.max(width - 2, 24))]
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
const handle = { hide() {}, list, component: wrap }
|
|
17
|
+
list.onSelect = (item) => {
|
|
18
|
+
handle.hide()
|
|
19
|
+
onSelect(item)
|
|
20
|
+
}
|
|
21
|
+
list.onCancel = () => {
|
|
22
|
+
handle.hide()
|
|
23
|
+
onCancel?.()
|
|
24
|
+
}
|
|
25
|
+
// 二次装配:showOverlay 返回真实 handle 后回填 hide(调用方调 picker.__bindHandle(real))
|
|
26
|
+
handle.__bindHandle = (h) => {
|
|
27
|
+
handle.hide = () => h.hide()
|
|
28
|
+
}
|
|
29
|
+
return handle
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 频道选择浮层。Enter 选择并切换;Esc 取消(fallback 频道由调用方决定)。
|
|
34
|
+
* @returns handle { close }
|
|
35
|
+
*/
|
|
36
|
+
export function openChannelPicker(tui, state, { onSelect, onCancel } = {}) {
|
|
37
|
+
const items = state.channels.map(c => ({
|
|
38
|
+
value: c.id,
|
|
39
|
+
label: c.name,
|
|
40
|
+
description: c.leadAgentId ? 'lead 已配置' : '无 lead(仅收发消息)',
|
|
41
|
+
}))
|
|
42
|
+
const picker = pickerComponent('选择要进入的频道(↑↓ 移动 · Enter 进入 · Esc 取消)', items, Math.min(10, items.length),
|
|
43
|
+
(item) => {
|
|
44
|
+
state.channelPickerOpen = false
|
|
45
|
+
onSelect?.(item.value)
|
|
46
|
+
},
|
|
47
|
+
() => {
|
|
48
|
+
state.channelPickerOpen = false
|
|
49
|
+
onCancel?.()
|
|
50
|
+
})
|
|
51
|
+
state.channelPickerOpen = true
|
|
52
|
+
const handle = tui.showOverlay(picker.component, { anchor: 'center' })
|
|
53
|
+
picker.__bindHandle(handle)
|
|
54
|
+
tui.setFocus(picker.list)
|
|
55
|
+
return handle
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Agent/对话目标选择浮层(Tab 触发)。首项 = 频道(发布任务模式);
|
|
60
|
+
* 选具体成员 = 切换通信目标并自动打开其独立监控。
|
|
61
|
+
*/
|
|
62
|
+
export function openTargetPicker(tui, state, { onPick, onCancel } = {}) {
|
|
63
|
+
const s = state.agents
|
|
64
|
+
.filter(a => a.enabled !== 0)
|
|
65
|
+
.map(a => ({
|
|
66
|
+
value: a.id,
|
|
67
|
+
label: `${a.name}${a.role === 'lead' ? '(lead)' : ''}`,
|
|
68
|
+
description: `通信对话 + 独立监控 · ${state.agentStates[a.id]?.state ?? 'idle'}`,
|
|
69
|
+
}))
|
|
70
|
+
const items = [{ value: '__channel__', label: '频道(发布任务)', description: '普通文本 = 向频道发布正式任务' }, ...s]
|
|
71
|
+
if (items.length === 1 && s.length === 0) {
|
|
72
|
+
onCancel?.()
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
const picker = pickerComponent('选择对话目标(Tab 呼出 · ↑↓ 移动 · Enter 确认 · Esc 取消)', items, Math.min(10, items.length),
|
|
76
|
+
(item) => {
|
|
77
|
+
if (process.env.AW_TUI_DEBUG) console.error('[picker-debug] item=', JSON.stringify(item), 'agentIds=', JSON.stringify(s.map(x => x.value)))
|
|
78
|
+
state.targetPickerOpen = false
|
|
79
|
+
if (item.value === '__channel__') {
|
|
80
|
+
state.target = null
|
|
81
|
+
onPick?.(null)
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const a = state.agents.find(x => x.id === item.value)
|
|
85
|
+
if (!a) {
|
|
86
|
+
onCancel?.()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
state.target = { agentId: a.id, name: a.name }
|
|
90
|
+
onPick?.(a)
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
() => {
|
|
94
|
+
state.targetPickerOpen = false
|
|
95
|
+
onCancel?.()
|
|
96
|
+
})
|
|
97
|
+
state.targetPickerOpen = true
|
|
98
|
+
const handle = tui.showOverlay(picker.component, { anchor: 'center' })
|
|
99
|
+
picker.__bindHandle(handle)
|
|
100
|
+
tui.setFocus(picker.list)
|
|
101
|
+
return handle
|
|
102
|
+
}
|
package/tui/lib/state.mjs
CHANGED
|
@@ -28,6 +28,12 @@ export function createState() {
|
|
|
28
28
|
/** 正在作答的条目(index → /hitl <n>;null = 常规输入) */
|
|
29
29
|
hitlAnswering: null,
|
|
30
30
|
|
|
31
|
+
/** 对话目标:null = 频道(普通文本=发布任务);{agentId,name} = 成员(普通文本=通信消息) */
|
|
32
|
+
target: null,
|
|
33
|
+
/** 浮层开关(按键路由用:Tab 呼出目标选择;启动/`/channel` 呼出频道选择) */
|
|
34
|
+
channelPickerOpen: false,
|
|
35
|
+
targetPickerOpen: false,
|
|
36
|
+
|
|
31
37
|
monitor: {
|
|
32
38
|
agentId: null,
|
|
33
39
|
name: null,
|