dsh-agent-foreman 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +235 -3
- package/README.zh.md +250 -0
- package/cordis.patch.yml +11 -0
- package/docs/settings-roster.png +0 -0
- package/icon.svg +8 -0
- package/lib/catalog.js +137 -0
- package/lib/client.js +433 -0
- package/lib/command.js +101 -0
- package/lib/engine.js +1292 -0
- package/lib/index.js +173 -0
- package/lib/page.js +881 -0
- package/lib/routes.js +525 -0
- package/lib/skill.js +79 -0
- package/lib/store.js +705 -0
- package/lib/team.js +1102 -0
- package/lib/tools.js +506 -0
- package/lib/whitelist.js +320 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包工头 · 客户端半边(浏览器侧)。
|
|
3
|
+
*
|
|
4
|
+
* 只做一件事:把宿主半边吐的设置页放进「设置 → 包工头」这个原生座位里。
|
|
5
|
+
*
|
|
6
|
+
* DSH 的客户端插件不经过打包器,宿主用 `window.__ModuleLoader__.load({ id, factory })`
|
|
7
|
+
* 加载它,`factory` 拿到的 `require` 只能解析宿主共享的运行时模块,所以这里
|
|
8
|
+
* 手写 `react.createElement`、不写 JSX、不引第三方库。
|
|
9
|
+
*
|
|
10
|
+
* 面板先探测宿主路由:iframe 加载失败时浏览器不给任何信息,不如自己先探一次,
|
|
11
|
+
* 把"连不上/什么状态码"直接写出来。
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-agent-foreman/client
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: 'dsh-agent-foreman',
|
|
18
|
+
factory: (require) => {
|
|
19
|
+
const module = { exports: {} };
|
|
20
|
+
const exports = module.exports;
|
|
21
|
+
|
|
22
|
+
const react = require('react');
|
|
23
|
+
const h = react.createElement;
|
|
24
|
+
|
|
25
|
+
/** 宿主半边挂载的页面地址。 */
|
|
26
|
+
const DASHBOARD_URL = '/plugins/dsh-agent-foreman/';
|
|
27
|
+
/** 探测用:state 接口同时能验证"路由在不在"和"名册读得出来"。 */
|
|
28
|
+
const STATE_URL = '/plugins/dsh-agent-foreman/api/state';
|
|
29
|
+
/** 座位 id。 */
|
|
30
|
+
const SEAT_ID = 'dsh-agent-foreman';
|
|
31
|
+
|
|
32
|
+
/** 语言:跟着宿主 locale 走,读不到就当中文。 */
|
|
33
|
+
let localeService;
|
|
34
|
+
function langOf() {
|
|
35
|
+
try {
|
|
36
|
+
const active = localeService?.getLocale?.()?.active;
|
|
37
|
+
return typeof active === 'string' ? active : 'zh';
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return 'zh';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function zh() {
|
|
44
|
+
return langOf() !== 'en';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 把设置页用的 `/api/state` 折成面板要的形状。
|
|
49
|
+
*
|
|
50
|
+
* 用途只有一条:宿主进程比插件旧、`/api/live` 还没注册时,至少把名册显示
|
|
51
|
+
* 出来,而不是整块面板报"连不上"——但要说清共享任务为什么是空的。
|
|
52
|
+
*
|
|
53
|
+
* @param state - `/api/state` 的响应体。
|
|
54
|
+
* @returns 与 `/api/live` 同构的对象(本轮任务为空)。
|
|
55
|
+
*/
|
|
56
|
+
function stateToLive(state) {
|
|
57
|
+
const members = Array.isArray(state?.members) ? state.members : [];
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
lead: { name: zh() ? '包工头' : 'Foreman', status: 'idle', waiting: false },
|
|
61
|
+
members: members.map(member => ({
|
|
62
|
+
name: typeof member?.name === 'string' ? member.name : '',
|
|
63
|
+
role: typeof member?.role === 'string' ? member.role : '',
|
|
64
|
+
specialty: typeof member?.specialty === 'string' ? member.specialty : '',
|
|
65
|
+
maxParallel: Number.isFinite(member?.maxParallel) ? member.maxParallel : 1,
|
|
66
|
+
model: typeof member?.model === 'string' ? member.model : '',
|
|
67
|
+
enabled: member?.enabled !== false,
|
|
68
|
+
status: 'idle',
|
|
69
|
+
})),
|
|
70
|
+
run: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const TITLE = { fontSize: '14px', fontWeight: '600', margin: '0 0 2px' };
|
|
75
|
+
const SUBTITLE = { fontSize: '12px', color: 'var(--fg-muted, #6b7280)', margin: '0 0 8px' };
|
|
76
|
+
const NOTE = { fontSize: '12px', color: 'var(--fg-muted, #6b7280)', lineHeight: '1.6' };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 探测 → 放 iframe。探测失败时把原因写出来,而不是给一块空白。
|
|
80
|
+
*
|
|
81
|
+
* @param props.title - iframe 标题(无障碍)。
|
|
82
|
+
*/
|
|
83
|
+
function HostFrame({ title }) {
|
|
84
|
+
const [probe, setProbe] = react.useState({ state: 'probing' });
|
|
85
|
+
react.useEffect(() => {
|
|
86
|
+
let cancelled = false;
|
|
87
|
+
fetch(STATE_URL, { headers: { accept: 'application/json' } })
|
|
88
|
+
.then(async (res) => {
|
|
89
|
+
if (cancelled)
|
|
90
|
+
return;
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
setProbe({ state: 'failed', detail: `HTTP ${res.status}` });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const body = await res.json().catch(() => undefined);
|
|
96
|
+
const members = Array.isArray(body?.members) ? body.members.length : 0;
|
|
97
|
+
setProbe({ state: 'ready', members });
|
|
98
|
+
})
|
|
99
|
+
.catch((error) => {
|
|
100
|
+
if (!cancelled)
|
|
101
|
+
setProbe({ state: 'failed', detail: String(error?.message ?? error) });
|
|
102
|
+
});
|
|
103
|
+
return () => {
|
|
104
|
+
cancelled = true;
|
|
105
|
+
};
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
if (probe.state === 'probing')
|
|
109
|
+
return h('div', { style: NOTE }, zh() ? '正在连接包工头…' : 'Connecting to the foreman…');
|
|
110
|
+
if (probe.state === 'failed') {
|
|
111
|
+
return h(
|
|
112
|
+
'div',
|
|
113
|
+
{ style: NOTE },
|
|
114
|
+
zh() ? '包工头面板加载失败:' : 'The Foreman panel could not load: ',
|
|
115
|
+
String(probe.detail ?? ''),
|
|
116
|
+
h('br'),
|
|
117
|
+
zh()
|
|
118
|
+
? '确认插件 dsh-agent-foreman 已启用,然后刷新页面。'
|
|
119
|
+
: 'Check that the dsh-agent-foreman plugin is enabled, then reload.',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return h('iframe', {
|
|
123
|
+
src: DASHBOARD_URL,
|
|
124
|
+
title,
|
|
125
|
+
style: {
|
|
126
|
+
width: '100%',
|
|
127
|
+
height: '100%',
|
|
128
|
+
border: '0',
|
|
129
|
+
display: 'block',
|
|
130
|
+
background: 'transparent',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 设置页里的那一节。 */
|
|
136
|
+
function ForemanSettings() {
|
|
137
|
+
return h(
|
|
138
|
+
'div',
|
|
139
|
+
{
|
|
140
|
+
style: {
|
|
141
|
+
display: 'flex',
|
|
142
|
+
flexDirection: 'column',
|
|
143
|
+
gap: '8px',
|
|
144
|
+
height: '100%',
|
|
145
|
+
minHeight: '0',
|
|
146
|
+
padding: '2px',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
h('div', { style: TITLE }, zh() ? '包工头' : 'Foreman'),
|
|
150
|
+
h(
|
|
151
|
+
'div',
|
|
152
|
+
{ style: SUBTITLE },
|
|
153
|
+
zh()
|
|
154
|
+
? '承包商名册、派单策略与最近派单记录。聊天里派单和验收的结果会同步到这里。'
|
|
155
|
+
: 'Contractor roster, dispatch policy, and recent runs. Dispatch and review results from the chat show up here too.',
|
|
156
|
+
),
|
|
157
|
+
h(
|
|
158
|
+
'div',
|
|
159
|
+
// 宿主把这一节包在自动高度的盒子里,百分比高度解析不出来;用
|
|
160
|
+
// 视口尺寸的固定高度,overflow 交给宿主的滚动容器。
|
|
161
|
+
{ style: { flex: '1 1 auto', height: 'min(78vh, 720px)', minHeight: '340px', overflow: 'hidden' } },
|
|
162
|
+
h(HostFrame, { title: zh() ? '包工头' : 'Foreman' }),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
/** 成员面板轮询地址;会话 id 由宿主的会话头座位传进来。 */
|
|
169
|
+
const LIVE_URL = '/plugins/dsh-agent-foreman/api/live';
|
|
170
|
+
|
|
171
|
+
/** 工单状态的措辞。 */
|
|
172
|
+
function orderStatusText(status) {
|
|
173
|
+
const zhText = { queued: '排队中', running: '进行中', done: '已完成', failed: '失败', timeout: '超时' };
|
|
174
|
+
const enText = { queued: 'queued', running: 'running', done: 'done', failed: 'failed', timeout: 'timed out' };
|
|
175
|
+
const table = zh() ? zhText : enText;
|
|
176
|
+
return table[status] ?? status ?? '';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 派单状态的措辞。 */
|
|
180
|
+
function runStatusText(status) {
|
|
181
|
+
const zhText = { running: '干活中', rework: '返工中', 'waiting-review': '待验收', accepted: '已验收', closed: '已结案' };
|
|
182
|
+
const enText = { running: 'working', rework: 'rework', 'waiting-review': 'awaiting review', accepted: 'accepted', closed: 'closed' };
|
|
183
|
+
const table = zh() ? zhText : enText;
|
|
184
|
+
return table[status] ?? status ?? '';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const PANEL = {
|
|
188
|
+
position: 'fixed',
|
|
189
|
+
zIndex: '90',
|
|
190
|
+
boxSizing: 'border-box',
|
|
191
|
+
display: 'flex',
|
|
192
|
+
flexDirection: 'column',
|
|
193
|
+
padding: '6px 2px 8px',
|
|
194
|
+
maxHeight: 'min(62vh, 540px)',
|
|
195
|
+
borderRadius: '12px',
|
|
196
|
+
border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.3))',
|
|
197
|
+
// 用不透明的层级色打底:宿主的 --dsw-specific-menu 是给带 backdrop-filter
|
|
198
|
+
// 的伪元素当底色的半透明色,直接当 background 会透出对话正文。
|
|
199
|
+
background: 'var(--dsw-alias-bg-layer-1, #232327)',
|
|
200
|
+
boxShadow: '0 16px 40px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.28)',
|
|
201
|
+
color: 'var(--dsw-alias-label-primary, #e8e8ea)',
|
|
202
|
+
fontSize: '12px',
|
|
203
|
+
textAlign: 'left',
|
|
204
|
+
overflow: 'hidden',
|
|
205
|
+
};
|
|
206
|
+
const PANEL_BODY = {
|
|
207
|
+
overflowY: 'auto',
|
|
208
|
+
minHeight: '0',
|
|
209
|
+
padding: '2px 12px 6px',
|
|
210
|
+
scrollbarWidth: 'thin',
|
|
211
|
+
};
|
|
212
|
+
const TRIGGER = {
|
|
213
|
+
display: 'inline-flex',
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
gap: '5px',
|
|
216
|
+
padding: '3px 7px',
|
|
217
|
+
minHeight: '28px',
|
|
218
|
+
border: '0',
|
|
219
|
+
borderRadius: '6px',
|
|
220
|
+
background: 'transparent',
|
|
221
|
+
color: 'var(--dsw-alias-label-tertiary, #9a9aa2)',
|
|
222
|
+
fontSize: '12px',
|
|
223
|
+
cursor: 'pointer',
|
|
224
|
+
};
|
|
225
|
+
const COUNT_PILL = {
|
|
226
|
+
padding: '0 5px',
|
|
227
|
+
borderRadius: '6px',
|
|
228
|
+
background: 'var(--dsw-alias-bg-layer-3, rgba(128,128,128,.18))',
|
|
229
|
+
color: 'var(--dsw-alias-label-caption, #8b8b93)',
|
|
230
|
+
fontSize: '11px',
|
|
231
|
+
lineHeight: '16px',
|
|
232
|
+
fontVariantNumeric: 'tabular-nums',
|
|
233
|
+
};
|
|
234
|
+
const SECTION_TITLE = {
|
|
235
|
+
display: 'flex',
|
|
236
|
+
alignItems: 'center',
|
|
237
|
+
gap: '6px',
|
|
238
|
+
margin: '12px 0 8px',
|
|
239
|
+
fontSize: '12px',
|
|
240
|
+
fontWeight: '600',
|
|
241
|
+
color: 'var(--dsw-alias-label-secondary, #b9b9c0)',
|
|
242
|
+
};
|
|
243
|
+
const CARD = {
|
|
244
|
+
display: 'flex',
|
|
245
|
+
alignItems: 'center',
|
|
246
|
+
gap: '9px',
|
|
247
|
+
padding: '8px 10px',
|
|
248
|
+
borderRadius: '8px',
|
|
249
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.22))',
|
|
250
|
+
background: 'var(--dsw-alias-bg-layer-2, rgba(128,128,128,.1))',
|
|
251
|
+
};
|
|
252
|
+
const DOT = { width: '8px', height: '8px', borderRadius: '50%', flex: 'none' };
|
|
253
|
+
const SMALL = { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #9a9aa2)', lineHeight: '1.7' };
|
|
254
|
+
const TAG = {
|
|
255
|
+
marginLeft: 'auto',
|
|
256
|
+
flex: 'none',
|
|
257
|
+
padding: '0 6px',
|
|
258
|
+
borderRadius: '6px',
|
|
259
|
+
fontSize: '10px',
|
|
260
|
+
lineHeight: '16px',
|
|
261
|
+
color: 'var(--dsw-alias-label-tertiary, #9a9aa2)',
|
|
262
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.28))',
|
|
263
|
+
};
|
|
264
|
+
/** 会话头的小人图标;跟原生 Agent Teams 的入口同一种语汇。 */
|
|
265
|
+
function peopleIcon() {
|
|
266
|
+
return h(
|
|
267
|
+
'svg',
|
|
268
|
+
{
|
|
269
|
+
width: '14',
|
|
270
|
+
height: '14',
|
|
271
|
+
viewBox: '0 0 16 16',
|
|
272
|
+
fill: 'none',
|
|
273
|
+
stroke: 'currentColor',
|
|
274
|
+
strokeWidth: '1.2',
|
|
275
|
+
strokeLinecap: 'round',
|
|
276
|
+
'aria-hidden': 'true',
|
|
277
|
+
},
|
|
278
|
+
h('path', { d: 'M6 7.2a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4Z' }),
|
|
279
|
+
h('path', { d: 'M2.2 12.8c0-1.9 1.7-3.2 3.8-3.2s3.8 1.3 3.8 3.2' }),
|
|
280
|
+
h('path', { d: 'M11.3 7.3a1.7 1.7 0 1 0 0-3.4' }),
|
|
281
|
+
h('path', { d: 'M12.4 9.9c1 .4 1.6 1.4 1.6 2.7' }),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 工地面板:轮询 /api/live,显示包工头与承包商的实时状态。 */
|
|
286
|
+
function WorksitePanel({ onClose }) {
|
|
287
|
+
const [live, setLive] = react.useState(null);
|
|
288
|
+
react.useEffect(() => {
|
|
289
|
+
let cancelled = false;
|
|
290
|
+
const tick = () => {
|
|
291
|
+
fetch(LIVE_URL, { headers: { accept: 'application/json' } })
|
|
292
|
+
.then(res => res.json())
|
|
293
|
+
.then(body => { if (!cancelled) setLive(body); })
|
|
294
|
+
.catch(() => {});
|
|
295
|
+
};
|
|
296
|
+
tick();
|
|
297
|
+
const timer = setInterval(() => { if (!document.hidden) tick(); }, 2500);
|
|
298
|
+
return () => { cancelled = true; clearInterval(timer); };
|
|
299
|
+
}, []);
|
|
300
|
+
const t = s => (zh() ? s : s);
|
|
301
|
+
return h('div', {
|
|
302
|
+
style: {
|
|
303
|
+
position: 'absolute', top: '36px', right: '0', zIndex: 100,
|
|
304
|
+
width: '340px', maxHeight: '480px', overflowY: 'auto',
|
|
305
|
+
background: 'var(--dsw-alias-bg-layer-1, #232327)', color: 'var(--dsw-alias-label-primary, #e8e8ea)',
|
|
306
|
+
border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.3))',
|
|
307
|
+
borderRadius: '10px', padding: '12px', fontSize: '12px',
|
|
308
|
+
boxShadow: '0 16px 40px rgba(0,0,0,.45)',
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' } },
|
|
312
|
+
h('strong', null, zh() ? '工地' : 'Job Site'),
|
|
313
|
+
h('button', { onClick: onClose, style: { background: 'none', border: 'none', color: 'var(--dsw-alias-label-tertiary, #9a9aa2)', cursor: 'pointer', fontSize: '14px' } }, '✕'),
|
|
314
|
+
),
|
|
315
|
+
live === null
|
|
316
|
+
? h('div', { style: { color: 'var(--dsw-alias-label-tertiary, #9a9aa2)' } }, zh() ? '加载中…' : 'Loading…')
|
|
317
|
+
: h(WorksiteBoard, { live }),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 工地看板本体:包工头 + 承包商 + 本轮工单。 */
|
|
322
|
+
function WorksiteBoard({ live }) {
|
|
323
|
+
const lead = live.lead || {};
|
|
324
|
+
const members = live.members || [];
|
|
325
|
+
const run = live.run;
|
|
326
|
+
const t = s => (zh() ? s : s);
|
|
327
|
+
return h('div', null,
|
|
328
|
+
// 包工头状态
|
|
329
|
+
h('div', { style: { marginBottom: '8px', padding: '6px 8px', borderRadius: '6px', background: 'var(--dsw-alias-bg-layer-2, rgba(128,128,128,.1))' } },
|
|
330
|
+
h('span', { style: { fontWeight: '600' } }, '👷 ' + (lead.name || t('包工头'))),
|
|
331
|
+
h('span', { style: { marginLeft: '6px', color: lead.status === 'running' ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary, #9a9aa2)' } },
|
|
332
|
+
lead.status === 'running' ? (zh() ? '· 干活中' : '· working')
|
|
333
|
+
: lead.waiting ? (zh() ? '· 待验收' : '· awaiting review')
|
|
334
|
+
: (zh() ? '· 空闲' : '· idle')),
|
|
335
|
+
),
|
|
336
|
+
// 承包商网格
|
|
337
|
+
h('div', { style: { fontWeight: '600', margin: '8px 0 4px' } }, zh() ? '承包商' : 'Contractors'),
|
|
338
|
+
members.map(m => h('div', {
|
|
339
|
+
key: m.id || m.name,
|
|
340
|
+
style: { display: 'flex', alignItems: 'center', gap: '6px', padding: '3px 0', borderBottom: '1px dashed var(--dsw-alias-border-l2, rgba(128,128,128,.15))' },
|
|
341
|
+
},
|
|
342
|
+
h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', flex: 'none', background: m.status === 'running' ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-bg-layer-3, rgba(128,128,128,.3))' } }),
|
|
343
|
+
h('span', { style: { fontWeight: '600' } }, m.name || ''),
|
|
344
|
+
h('span', { style: { color: 'var(--dsw-alias-label-tertiary, #9a9aa2)', flex: '1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, m.model || (zh() ? '跟随父会话' : 'inherit')),
|
|
345
|
+
h('span', { style: { color: 'var(--dsw-alias-label-tertiary, #9a9aa2)' } }, (m.orders || 0) + ' 单'),
|
|
346
|
+
)),
|
|
347
|
+
// 本轮工单
|
|
348
|
+
run ? h('div', null,
|
|
349
|
+
h('div', { style: { fontWeight: '600', margin: '8px 0 4px' } }, zh() ? '本轮工单' : 'Current orders'),
|
|
350
|
+
(run.orders || []).map(o => h('div', {
|
|
351
|
+
key: o.id,
|
|
352
|
+
style: { padding: '3px 0', borderBottom: '1px dashed var(--dsw-alias-border-l2, rgba(128,128,128,.15))' },
|
|
353
|
+
},
|
|
354
|
+
h('span', { style: { fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #9a9aa2)' } },
|
|
355
|
+
o.status === 'done' ? '✔ ' : o.status === 'skipped' ? '⊘ ' : (o.status === 'failed' || o.status === 'timeout') ? '✖ ' : '· '),
|
|
356
|
+
h('span', null, o.id + ' ' + (o.title || '')),
|
|
357
|
+
h('div', { style: { color: 'var(--dsw-alias-label-tertiary, #9a9aa2)', fontSize: '11px', marginLeft: '14px' } },
|
|
358
|
+
(o.contractor || '') + (o.route ? ' · ' + o.route : '')),
|
|
359
|
+
o.error ? h('div', { style: { color: 'var(--dsw-alias-label-tertiary, #9a9aa2)', fontSize: '11px', marginLeft: '14px' } }, o.error) : null,
|
|
360
|
+
)),
|
|
361
|
+
) : h('div', { style: { color: 'var(--dsw-alias-label-tertiary, #9a9aa2)', margin: '8px 0' } }, zh() ? '还没有派单' : 'No orders yet'),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 会话头的「工地」按钮 + 点开面板。 */
|
|
366
|
+
function WorksiteAction() {
|
|
367
|
+
const [open, setOpen] = react.useState(false);
|
|
368
|
+
return h('div', { style: { position: 'relative', display: 'inline-block' } },
|
|
369
|
+
h('button', {
|
|
370
|
+
onClick: () => setOpen(o => !o),
|
|
371
|
+
title: zh() ? '工地看板' : 'Job Site',
|
|
372
|
+
style: { display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '3px 8px', minHeight: '28px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.28))', borderRadius: '6px', background: 'transparent', color: 'var(--dsw-alias-label-secondary, #b9b9c0)', cursor: 'pointer', fontSize: '12px' },
|
|
373
|
+
}, '🏗 ' + (zh() ? '工地' : 'Job Site')),
|
|
374
|
+
open ? h(WorksitePanel, { onClose: () => setOpen(false) }) : null,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 挂载客户端半边。
|
|
380
|
+
* @param ctx - 客户端 cordis 上下文。
|
|
381
|
+
*/
|
|
382
|
+
function apply(ctx) {
|
|
383
|
+
ctx.inject(['locale'], (injected) => {
|
|
384
|
+
localeService = injected.get('locale');
|
|
385
|
+
return undefined;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const seats = [];
|
|
389
|
+
ctx.inject(['slots'], (injected) => {
|
|
390
|
+
const slots = injected.get('slots');
|
|
391
|
+
if (slots === undefined || typeof slots.register !== 'function' || typeof slots.inject !== 'function') {
|
|
392
|
+
console.warn('[dsh-agent-foreman] slots 服务不可用,设置页不会被注册。');
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
// 设置页:名册编辑 + 策略 + 历史。
|
|
396
|
+
const release = slots.inject('settings.section', () => slots.register({
|
|
397
|
+
name: 'settings.section',
|
|
398
|
+
id: SEAT_ID,
|
|
399
|
+
// 紧跟在 Cursor 那一节(62)之后,同属"子智能体为什么这么跑"的说明区。
|
|
400
|
+
order: 63,
|
|
401
|
+
label: () => (zh() ? '包工头' : 'Foreman'),
|
|
402
|
+
}, ForemanSettings));
|
|
403
|
+
seats.push(release);
|
|
404
|
+
// 会话头:「工地」按钮 + 实时看板(主面板,不藏在设置里)。
|
|
405
|
+
const releaseWorksite = slots.inject('conversation.session.header.actions', () => slots.register({
|
|
406
|
+
name: 'conversation.session.header.actions',
|
|
407
|
+
id: SEAT_ID + '-worksite',
|
|
408
|
+
order: -19,
|
|
409
|
+
label: () => (zh() ? '工地' : 'Job Site'),
|
|
410
|
+
}, WorksiteAction));
|
|
411
|
+
seats.push(releaseWorksite);
|
|
412
|
+
return undefined;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
ctx.effect(() => () => {
|
|
416
|
+
for (const release of seats) {
|
|
417
|
+
try {
|
|
418
|
+
release?.();
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
console.warn('[dsh-agent-foreman] 释放座位失败:', error);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
seats.length = 0;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
exports.name = 'dsh-agent-foreman-client';
|
|
429
|
+
exports.apply = apply;
|
|
430
|
+
exports.inject = [];
|
|
431
|
+
return module.exports;
|
|
432
|
+
},
|
|
433
|
+
});
|
package/lib/command.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/foreman` 的原生命令入口。
|
|
3
|
+
*
|
|
4
|
+
* 先说清楚为什么要走 `commands` 而不是只注册 skill:
|
|
5
|
+
*
|
|
6
|
+
* - skill(`ctx.skills.register`)的正文由宿主注入,省事,但菜单里只有英文 id,
|
|
7
|
+
* 没有「旁问 btw」那种中文标题——插件说了不算。
|
|
8
|
+
* - command(`ctx.commands.register`)有标题、有输入提示,能进 `/` 菜单的前排,
|
|
9
|
+
* 但 handler 只能返回给界面看的文字,**没法把指令送进模型**。
|
|
10
|
+
*
|
|
11
|
+
* 两边各缺一半,所以这里用 commands 的 `agent.steer()` 补上:命令触发时把「四步流程
|
|
12
|
+
* + 用户这句派单内容」当成一条用户消息塞进当前回合。dsh-cursor-dev 的 `/cursor-subagent`
|
|
13
|
+
* 就是这么做的(先跑子智能体,再把结果 steer 回去让父模型审查),走的是同一条宿主 API。
|
|
14
|
+
*
|
|
15
|
+
* 没有 commands 服务的宿主(无头 profile)会静默跳过,调用方退回 skill 入口。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-agent-foreman/command
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { foremanSkillContent, languageOf } from './skill.js';
|
|
21
|
+
|
|
22
|
+
/** 命令名:宿主只接受小写字母、数字与连字符。 */
|
|
23
|
+
export const COMMAND_NAME = 'foreman';
|
|
24
|
+
|
|
25
|
+
/** 一句话说明,显示在 `/` 菜单右侧。 */
|
|
26
|
+
const COMMAND_DESCRIPTION = '把任务拆成工单交给名册里的承包商各自干,父智能体最后逐单验收';
|
|
27
|
+
|
|
28
|
+
const USAGE = '用法:/foreman 把 A 抽成共享逻辑;给 B 补回归测试';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 拼出要塞进本回合的那条消息:流程正文 + 用户实际派单内容。
|
|
32
|
+
*
|
|
33
|
+
* @param rawInput - 用户敲在 `/foreman` 后面的原话。
|
|
34
|
+
* @param lang - 界面语言。
|
|
35
|
+
* @returns 消息正文。
|
|
36
|
+
*/
|
|
37
|
+
export function foremanSteerText(rawInput, lang) {
|
|
38
|
+
const body = foremanSkillContent(lang);
|
|
39
|
+
return [
|
|
40
|
+
body,
|
|
41
|
+
'',
|
|
42
|
+
'【本轮用户派单内容】(已由 /foreman 命令提供,不要再问他一遍)',
|
|
43
|
+
rawInput.trim(),
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 构造一条普通用户消息,和宿主自己提交的输入同形。
|
|
49
|
+
*
|
|
50
|
+
* @param text - 正文。
|
|
51
|
+
* @returns 冻结的消息对象。
|
|
52
|
+
*/
|
|
53
|
+
function userMessage(text) {
|
|
54
|
+
const message = {
|
|
55
|
+
id: globalThis.crypto.randomUUID(),
|
|
56
|
+
role: 'user',
|
|
57
|
+
content: [{ type: 'text', text }],
|
|
58
|
+
source: { kind: 'user' },
|
|
59
|
+
};
|
|
60
|
+
Object.freeze(message.content[0]);
|
|
61
|
+
Object.freeze(message.content);
|
|
62
|
+
Object.freeze(message);
|
|
63
|
+
return message;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 在宿主上注册 `/foreman` 命令。
|
|
68
|
+
*
|
|
69
|
+
* @param ctx - cordis 上下文。
|
|
70
|
+
* @param options - 可选的 logger。
|
|
71
|
+
* @returns 释放函数;宿主没有 commands 服务时返回 `undefined`。
|
|
72
|
+
*/
|
|
73
|
+
export function registerForemanCommand(ctx, options = {}) {
|
|
74
|
+
const commands = typeof ctx.get === 'function' ? ctx.get('commands') : undefined;
|
|
75
|
+
if (commands === undefined || typeof commands.register !== 'function')
|
|
76
|
+
return undefined;
|
|
77
|
+
const logger = options.logger;
|
|
78
|
+
return commands.register({
|
|
79
|
+
name: COMMAND_NAME,
|
|
80
|
+
description: COMMAND_DESCRIPTION,
|
|
81
|
+
input: { hint: '把要派的活写在这里,分号或换行分段' },
|
|
82
|
+
handler: (invocation) => {
|
|
83
|
+
const rawInput = typeof invocation?.rawInput === 'string' ? invocation.rawInput : '';
|
|
84
|
+
if (rawInput.trim() === '')
|
|
85
|
+
return { kind: 'error', text: USAGE };
|
|
86
|
+
const agent = invocation?.agent;
|
|
87
|
+
if (agent === undefined || typeof agent.steer !== 'function')
|
|
88
|
+
return { kind: 'error', text: '这个位置没有可用的会话,/foreman 要在对话里用' };
|
|
89
|
+
try {
|
|
90
|
+
agent.steer(userMessage(foremanSteerText(rawInput, languageOf(ctx))));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
logger?.warn?.(`dsh-agent-foreman: /foreman 注入失败 — ${message}`);
|
|
95
|
+
return { kind: 'error', text: `派单指令没能送进当前会话:${message}` };
|
|
96
|
+
}
|
|
97
|
+
logger?.info?.(`dsh-agent-foreman: /foreman 已接管本轮(${rawInput.trim().length} 字)`);
|
|
98
|
+
return { kind: 'success', text: '已交给包工头:先看名册,再拆单派下去,最后逐单验收。' };
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|