NewGoalForm.vue
20 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
<!--
「+ 新目标」两步表单(27-P3C §七.2)。内联展开在目标·任务栏里,是**唯一**的下达入口:
它承接的 DispatchTaskButton 已随本表单落地一并删除(GraphConsole 撤掉那两处引用后全仓库零调用方,
留一个没人用的组件只会让下一个人以为还有第二个下达入口)。仓库里已不存在该组件,别再去找。
为什么是两步:`task-planning` 拆出来的是**一次 LLM 猜测**。直接把 N 条任务塞进去跑,
等于让一次猜错的拆分烧掉 N 份预算;而在这一步改一改的成本几乎为零(§四.9)。
所以拆分产出一律是 draft,人确认过才「全部下达」。
三处刻意的设计:
- **成本预警原样保留**:真 CLI 一次中等任务 $10-20 / 1-2 小时,点下去才知道就晚了。
- **拆分等待必须能绕过**:几十秒的 LLM 等待,一次 agent 卡死就等于这个表单不能用了。
逃生口走 `POST /:cid/tasks`(带 goal_id)—— 那条路是「建一条同描述的 task 并立刻起 run」,
正是「按单任务下达」。后台那次拆分不会被取消(没有取消端点),产出会作为草稿落到同一个
goal 下,所以按钮下方**明说**这件事,而不是让人事后发现多了几条任务。
- **拆分失败不弹错误框**:直接展示降级结果 + 一句说明。降级本身是成功路径的一部分。
⚠️ 已知缺口:「加行」是灰的。后端没有「在既有 goal 下建一条 **draft** task」的端点 ——
`POST /:cid/tasks` 建完就立刻起 run(批 1 语义),拿它当加行会静默把一条草稿变成正在跑的任务。
按钮留在这里并写明原因,比悄悄不做或者悄悄做错都好。
-->
<template>
<div class="ngf">
<button v-if="phase === 'closed'" class="btn primary block" @click="openForm">+ 新目标</button>
<div v-else class="panel">
<!-- ── 第一步 · 写需求 ─────────────────────────────────────────── -->
<template v-if="phase === 'step1' || phase === 'creating'">
<div class="steps"><b>1 写需求</b><span>›</span><span>2 确认拆分</span></div>
<label class="lbl">目标标题(可留空,自动取描述首行)</label>
<input v-model="title" class="in" :disabled="busy" placeholder="例如:做个官网" />
<label class="lbl">需求描述 —— 这段话就是验收边界</label>
<textarea
ref="ta"
v-model="description"
class="ta"
rows="4"
:disabled="busy"
placeholder="用自然语言说清要做什么,例如:做一个能跑起来的官网,含首页与关于页,移动端不能错位"
@keydown.meta.enter="submitStep1"
@keydown.ctrl.enter="submitStep1"
/>
<label class="lbl">执行模式</label>
<div class="modes">
<label class="mode" :class="{ on: mode === 'manual' }">
<input v-model="mode" type="radio" value="manual" :disabled="busy" />
<span class="m-t">手动</span><span class="m-d">每点一次「下一步」推进一个节点</span>
</label>
<label class="mode" :class="{ on: mode === 'auto' }">
<input v-model="mode" type="radio" value="auto" :disabled="busy" />
<span class="m-t">自动</span><span class="m-d">一次授权,从头跑到尾</span>
</label>
</div>
<p v-if="mode === 'auto'" class="warn">
⚠️ 自动模式会连续调用真实 agent。一次中等任务约 <b>60-100 次调用 / $10-20 / 1-2 小时</b>;
跑起来后可以随时「暂停」,但已经发出的那次调用不会被打断。
</p>
<label class="switch">
<input v-model="split" type="checkbox" :disabled="busy" />
<span class="s-t">自动拆分任务</span>
<span class="s-d">调 <code>task-planning</code> 把需求拆成有依赖关系的多条任务(几十秒,会消耗 token)</span>
</label>
<p v-if="err" class="err">{{ err }}<span v-if="hint" class="hint"> · {{ hint }}</span></p>
<div class="acts">
<button class="btn primary" :disabled="!description.trim() || busy" @click="submitStep1">
{{ busy ? '创建中…' : (split ? '下一步' : '创建草稿') }}
</button>
<button class="btn ghost" :disabled="busy" @click="close">取消</button>
<span class="kbd">⌘/Ctrl + Enter</span>
</div>
</template>
<!-- ── 拆分中 · 骨架屏 + 逃生口 ────────────────────────────────── -->
<template v-else-if="phase === 'splitting'">
<div class="steps"><span>1 写需求</span><span>›</span><b>2 确认拆分</b></div>
<p class="planning">⏳ 正在拆分…<em>task-planning 通常要几十秒</em></p>
<div class="skel"><i /><i /><i /></div>
<p v-if="pollErr" class="err">{{ pollErr }}</p>
<div class="acts col">
<button class="btn ghost" :disabled="busy" @click="escapeSingle">
{{ busy ? '下达中…' : '跳过,按单任务下达' }}
</button>
<span class="foot">
注意:后台那次拆分不会被取消(没有取消端点),它的产出会作为草稿任务落到同一个目标下,可自行删除。
</span>
<button class="btn ghost" :disabled="busy" @click="close">先关掉,回列表看</button>
</div>
</template>
<!-- ── 第二步 · 确认拆分 ───────────────────────────────────────── -->
<template v-else>
<div class="steps"><span>1 写需求</span><span>›</span><b>2 确认拆分</b></div>
<p v-if="planningError" class="degraded">
自动拆分失败,已降级为 1 条同描述的任务(草稿)。<em>{{ planningError }}</em>
</p>
<p v-else-if="planningTokens" class="tokens">本次拆分消耗 ~{{ planningTokens.toLocaleString() }} tokens</p>
<ul class="rows">
<li v-for="t in tasks" :key="t.taskId" class="row">
<div class="r-head">
<span class="seq">#{{ t.seq }}</span>
<span class="r-title">{{ t.title }}</span>
<button class="x" title="删除这条任务(归档)" :disabled="busy" @click="removeTask(t)">✕</button>
</div>
<textarea
class="ta small"
rows="2"
:value="t.description"
:disabled="busy"
@change="patchDescription(t, $event.target.value)"
/>
<div v-if="tasks.length > 1" class="deps">
<span class="d-lbl">依赖前置:</span>
<button
v-for="o in tasks.filter((x) => x.taskId !== t.taskId)"
:key="o.taskId"
class="chip"
:class="{ on: (t.dependsOn || []).includes(o.taskId) }"
:disabled="busy"
:title="`点一下切换:本条是否等 #${o.seq} 完成`"
@click="toggleDep(t, o)"
>#{{ o.seq }}</button>
</div>
</li>
</ul>
<button class="btn ghost block" disabled :title="ADD_ROW_DISABLED">+ 加一条任务(暂不可用)</button>
<p class="foot">{{ ADD_ROW_DISABLED }}</p>
<p v-if="err" class="err">{{ err }}<span v-if="hint" class="hint"> · {{ hint }}</span></p>
<div class="acts">
<button class="btn primary" :disabled="!tasks.length || busy" @click="dispatchAll">
{{ busy ? '下达中…' : '全部下达' }}
</button>
<button class="btn ghost" :disabled="busy" @click="close">存草稿</button>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, onBeforeUnmount } from 'vue';
const props = defineProps({ companyId: { type: String, required: true } });
/**
* changed —— 库里的 goal/task 变了,父组件重拉整棵树(建 goal / 改 task / 删 task 都发)。
* dispatched —— 起了 run,父组件除了重拉还要把选中态挪过去。
*/
const emit = defineEmits(['changed', 'dispatched']);
const ADD_ROW_DISABLED =
'后端暂无「在既有目标下建一条草稿任务」的端点:POST /:cid/tasks 建完会立刻起 run,'
+ '拿它当加行会把草稿静默变成正在跑的任务。需要加任务时先「全部下达」再新建一个目标。';
/** closed → step1 →(split? splitting : —)→ step2。creating 是 step1 的提交中态 */
const phase = ref('closed');
const busy = ref(false);
const err = ref('');
const hint = ref('');
const pollErr = ref('');
const title = ref('');
const description = ref('');
const mode = ref('manual');
const split = ref(true);
const ta = ref(null);
const goalId = ref('');
const tasks = ref([]);
const planningTokens = ref(0);
const planningError = ref('');
let poller = null;
onBeforeUnmount(stopPoll);
function stopPoll() {
if (poller) { clearTimeout(poller); poller = null; }
}
async function openForm() {
phase.value = 'step1';
err.value = '';
hint.value = '';
await nextTick();
ta.value?.focus();
}
function close() {
stopPoll();
phase.value = 'closed';
title.value = '';
description.value = '';
goalId.value = '';
tasks.value = [];
planningTokens.value = 0;
planningError.value = '';
err.value = '';
hint.value = '';
pollErr.value = '';
emit('changed');
}
/** 统一的 fetch:把后端的 error / hint 原样透出去,不翻译成「出错了」(本目录的既有惯例) */
async function call(method, url, body) {
const r = await fetch(url, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
const e = new Error(d.error || `${method} ${url} 失败(HTTP ${r.status})`);
e.hint = d.hint || '';
throw e;
}
return d;
}
function fail(e) {
err.value = e.message;
hint.value = e.hint || '';
}
// ── 第一步 ────────────────────────────────────────────────────────────────
async function submitStep1() {
if (!description.value.trim() || busy.value) return;
busy.value = true;
err.value = '';
hint.value = '';
try {
const d = await call('POST', `/api/company/${props.companyId}/goals`, {
title: title.value.trim() || undefined,
description: description.value.trim(),
mode: mode.value,
split: split.value,
});
goalId.value = d.goalId;
emit('changed'); // goal 已经落库了,左栏立刻看得到(哪怕还在 planning)
if (d.split) {
phase.value = 'splitting';
schedulePoll();
} else {
adopt(d);
phase.value = 'step2';
}
} catch (e) {
fail(e);
} finally {
busy.value = false;
}
}
/** GET /goals/:id 的响应 → 本地状态 */
function adopt(d) {
tasks.value = d.tasks || [];
planningTokens.value = d.planningTokens || 0;
planningError.value = d.planningError || '';
}
/**
* 轮询等拆分(没有 goal 级 SSE 通道)。2s 一次、最多 3 分钟。
* 超时不是错误:runGoalSplit 的三条出口都会把 goal 推离 planning,超时只说明这次特别慢 ——
* 提示一句并留着逃生口,别把表单锁死。
*/
const POLL_MS = 2000;
const POLL_MAX = 90; // 90 × 2s = 3min
let polled = 0;
function schedulePoll() {
polled = 0;
pollErr.value = '';
tick();
}
function tick() {
stopPoll();
poller = setTimeout(async () => {
if (phase.value !== 'splitting') return;
try {
const d = await call('GET', `/api/company/${props.companyId}/goals/${goalId.value}`);
if (d.status !== 'planning') {
adopt(d);
phase.value = 'step2';
emit('changed');
return;
}
} catch (e) {
// 单次拉取失败不终止:网络抖一下不该把用户踢出等待
pollErr.value = `拉取拆分结果失败:${e.message}(仍在重试)`;
}
polled += 1;
if (polled >= POLL_MAX) {
pollErr.value = '拆分已经跑了 3 分钟还没出结果。可以点下面「跳过,按单任务下达」,或先关掉回列表等它自己收敛。';
return;
}
tick();
}, POLL_MS);
}
/** 逃生口:建一条同描述的 task 并立刻起 run(POST /:cid/tasks 就是这个语义) */
async function escapeSingle() {
if (busy.value) return;
busy.value = true;
err.value = '';
hint.value = '';
try {
const d = await call('POST', `/api/company/${props.companyId}/tasks`, {
goal_id: goalId.value,
task: description.value.trim(),
mode: mode.value,
});
stopPoll();
phase.value = 'closed';
emit('dispatched', { goalId: goalId.value, taskId: d.taskId, runId: d.runId });
close();
} catch (e) {
fail(e);
} finally {
busy.value = false;
}
}
// ── 第二步的编辑:每一笔立刻落库 ──────────────────────────────────────────
// 为什么不攒着到「全部下达」再一起提交:攒起来的编辑一旦中途关窗就全丢了,而这些行**已经在库里**
// (拆分产出就是 draft 行)。立刻落库之后「存草稿」按钮才名副其实 —— 它就是「关掉,什么都不做」。
async function patchDescription(t, value) {
const next = String(value || '').trim();
if (!next || next === t.description) return;
await write(() => call('PATCH', `/api/company/${props.companyId}/tasks/${t.taskId}`, { description: next }));
}
async function toggleDep(t, other) {
const cur = (t.dependsOn || []).slice();
const i = cur.indexOf(other.taskId);
if (i >= 0) cur.splice(i, 1); else cur.push(other.taskId);
// 环由数据层判(validateDependsOn 是唯一持有者),400 时原样透出来,不在前端复述一遍规则
await write(() => call('PATCH', `/api/company/${props.companyId}/tasks/${t.taskId}`, { depends_on: cur }));
}
async function removeTask(t) {
await write(() => call('DELETE', `/api/company/${props.companyId}/tasks/${t.taskId}`));
}
/** 一笔写 → 成功就重新拉整份 goal(seq / dependsOn 的翻译结果只有后端算得准),失败就透出错误 */
async function write(fn) {
if (busy.value) return;
busy.value = true;
err.value = '';
hint.value = '';
try {
await fn();
adopt(await call('GET', `/api/company/${props.companyId}/goals/${goalId.value}`));
emit('changed');
} catch (e) {
fail(e);
// 失败后同样重拉:否则界面上留着一个其实没生效的编辑结果
try { adopt(await call('GET', `/api/company/${props.companyId}/goals/${goalId.value}`)); } catch { /* 忽略 */ }
} finally {
busy.value = false;
}
}
async function dispatchAll() {
if (busy.value) return;
busy.value = true;
err.value = '';
hint.value = '';
try {
const d = await call('POST', `/api/company/${props.companyId}/goals/${goalId.value}/dispatch`);
const gid = goalId.value;
phase.value = 'closed';
emit('dispatched', { goalId: gid, taskId: d.started?.taskId, runId: d.started?.runId });
close();
} catch (e) {
// 409(已有任务在跑 / 上游没就绪)在这里是**正常结局**:任务已经是草稿了,稍后再下达即可。
// 服务端的 hint 里写着确切的自救路径,原样透出比翻译成「下达失败」有用得多。
fail(e);
} finally {
busy.value = false;
}
}
</script>
<style scoped>
/* 本页(GraphConsole)通篇硬编码浅色,这里跟着来,免得暗色主题下四栏里只有这一栏是变量色 */
.ngf { margin-bottom: 10px; }
.btn {
border: 1px solid #ddd; background: #fff; color: #1a1a1a;
border-radius: 6px; padding: 6px 12px; font-size: 12px; cursor: pointer;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }
.btn.ghost { background: transparent; }
.btn.block { width: 100%; }
.panel { border: 1px solid #e4e4e8; border-radius: 8px; padding: 10px; background: #fff; display: flex; flex-direction: column; gap: 6px; }
.steps { display: flex; gap: 6px; align-items: center; font-size: 11px; color: #aaa; margin-bottom: 2px; }
.steps b { color: #1a1a1a; }
.lbl { font-size: 11px; color: #777; font-weight: 600; }
.in, .ta {
width: 100%; box-sizing: border-box; font: inherit; font-size: 12px;
padding: 6px 8px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #1a1a1a;
}
.ta { resize: vertical; }
.ta.small { font-size: 11px; line-height: 1.5; }
.modes { display: flex; gap: 6px; }
.mode { flex: 1; display: flex; flex-direction: column; gap: 2px; cursor: pointer; border: 1px solid #ddd; border-radius: 6px; padding: 5px 7px; }
.mode.on { border-color: #2b6cb0; background: #f2f7fd; }
.mode input { display: none; }
.m-t { font-size: 12px; font-weight: 700; }
.m-d { font-size: 10px; color: #888; line-height: 1.3; }
.warn { margin: 0; font-size: 11px; line-height: 1.45; color: #7a5b00; background: #fffaf0; border: 1px dashed #e6d5a8; border-radius: 6px; padding: 6px 8px; }
.switch { display: grid; grid-template-columns: auto 1fr; gap: 2px 6px; align-items: center; cursor: pointer; border: 1px solid #ddd; border-radius: 6px; padding: 6px 8px; }
.switch input { grid-row: span 2; }
.s-t { font-size: 12px; font-weight: 700; }
.s-d { grid-column: 2; font-size: 10px; color: #888; line-height: 1.4; }
.switch code { background: #f2f2f4; padding: 0 3px; border-radius: 3px; }
.planning { margin: 4px 0 0; font-size: 12px; color: #2b6cb0; }
.planning em { font-style: normal; color: #999; margin-left: 6px; font-size: 11px; }
.skel { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; }
.skel i { display: block; height: 30px; border-radius: 6px; background: linear-gradient(90deg, #f2f2f4 25%, #e9e9ed 37%, #f2f2f4 63%); background-size: 400% 100%; animation: sk 1.4s ease infinite; }
@keyframes sk { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
.degraded { margin: 0; font-size: 11px; line-height: 1.5; color: #a35b00; background: #fff7ed; border: 1px solid #f0d9b8; border-radius: 6px; padding: 6px 8px; }
.degraded em { display: block; font-style: normal; color: #8a6a3a; margin-top: 3px; }
.tokens { margin: 0; font-size: 11px; color: #888; }
.rows { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.row { border: 1px solid #eee; border-radius: 6px; padding: 6px; background: #fcfcfd; display: flex; flex-direction: column; gap: 4px; }
.r-head { display: flex; align-items: center; gap: 6px; }
.seq { font-size: 10px; color: #fff; background: #b9bec7; border-radius: 8px; padding: 1px 6px; font-variant-numeric: tabular-nums; }
.r-title { font-size: 12px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.x { border: none; background: none; color: #bbb; cursor: pointer; font-size: 12px; padding: 0 2px; }
.x:hover:not(:disabled) { color: #d33; }
.deps { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.d-lbl { font-size: 10px; color: #999; }
.chip { border: 1px solid #ddd; background: #fff; color: #888; border-radius: 9px; padding: 1px 7px; font-size: 10px; cursor: pointer; }
.chip.on { border-color: #2b6cb0; background: #eef4fb; color: #2b6cb0; font-weight: 700; }
.chip:disabled { opacity: .5; cursor: not-allowed; }
.err { margin: 0; font-size: 11px; color: #dc2626; line-height: 1.5; }
.err .hint { color: #888; }
.foot { margin: 0; font-size: 10px; color: #999; line-height: 1.5; }
.acts { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.acts.col { flex-direction: column; align-items: stretch; }
.kbd { margin-left: auto; font-size: 10px; color: #bbb; }
</style>