DispatchTaskButton.vue
6.11 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
<!--
下达任务入口(27-P3B W14)
`POST /api/company/:cid/tasks` 此前**全仓库没有任何前端调用方** —— 界面走查时确认过:
用户在控制台上没有任何办法给公司下达任务,只能 curl。这是「实现在、零调用方」那类问题
在 P3 里的第 N 次出现(W8 刚给 replan 补上调用方,tasks 还欠着)。
两处刻意的设计:
- **手动/自动在下达时就要选**,不是跑起来再改:`execution_mode` 是 run 的属性,
起 run 时定;界面上后补一个下拉会让人以为能中途换。
- **自动模式在这里就给成本预警**:真 CLI 一次中等任务是 $3-8 / 15-40 分钟
(27-P3B §五),点下去才知道就晚了。GraphRunPane 的「启动」也有一道确认,
但那是针对已有 run 的,下达这一步是更早的闸门。
-->
<template>
<div class="dispatch" :class="{ open }">
<button v-if="!open" class="btn" :class="variant" @click="open = true">
✦ 下达任务
</button>
<div v-else class="form">
<label class="lbl">任务描述</label>
<textarea
ref="ta"
v-model="task"
class="ta"
rows="3"
placeholder="用一句自然语言说清要做什么,例如:实现一个最小可运行的接口,错误输入要有明确响应"
:disabled="busy"
@keydown.meta.enter="submit"
@keydown.ctrl.enter="submit"
/>
<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>
<p v-if="err" class="err">{{ err }}<span v-if="hint" class="hint"> · {{ hint }}</span></p>
<div class="acts">
<button class="btn primary" :disabled="!task.trim() || busy" @click="submit">
{{ busy ? '下达中…' : '下达' }}
</button>
<button class="btn ghost" :disabled="busy" @click="cancel">取消</button>
<span class="kbd">⌘/Ctrl + Enter</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, watch } from 'vue';
const props = defineProps({
companyId: { type: String, required: true },
variant: { type: String, default: 'primary' }, // primary | inline
});
const emit = defineEmits(['dispatched']);
const open = ref(false);
const task = ref('');
const mode = ref('manual');
const busy = ref(false);
const err = ref('');
const hint = ref('');
const ta = ref(null);
watch(open, async (v) => {
if (!v) return;
await nextTick();
ta.value?.focus();
});
function cancel() {
open.value = false;
err.value = '';
hint.value = '';
}
async function submit() {
if (!task.value.trim() || busy.value) return;
busy.value = true;
err.value = '';
hint.value = '';
try {
const r = await fetch(`/api/company/${props.companyId}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task: task.value.trim(), mode: mode.value }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok || !d.runId) {
// 409 = v1 并发约束「一家公司同时只有一个 active root run」,服务端会带 hint,
// 原样透出去比翻译成「出错了」有用得多
err.value = d.error || `下达失败(HTTP ${r.status})`;
hint.value = d.hint || '';
return;
}
open.value = false;
task.value = '';
emit('dispatched', d);
} catch (e) {
err.value = `下达失败:${e.message}`;
} finally {
busy.value = false;
}
}
</script>
<style scoped>
/* 收起时按内容宽,撑满会盖住同一行的「重新规划组织」;展开时才占满整列 */
.dispatch { min-width: 0; }
.dispatch.open { width: 100%; }
.btn {
border: 1px solid var(--border-primary); background: var(--bg-surface); color: var(--text-primary);
border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.primary { background: var(--accent, #2563eb); border-color: transparent; color: #fff; font-weight: 600; }
.btn.inline { padding: 4px 10px; font-size: 12px; }
.btn.ghost { background: transparent; }
.form { display: flex; flex-direction: column; gap: 6px; text-align: left; }
.lbl { font-size: 11px; color: var(--text-secondary); font-weight: 600; }
.ta {
width: 100%; box-sizing: border-box; resize: vertical; font: inherit; font-size: 13px;
padding: 8px; border: 1px solid var(--border-primary); border-radius: 6px;
background: var(--bg-surface); color: var(--text-primary);
}
.modes { display: flex; gap: 6px; }
.mode {
flex: 1; display: flex; flex-direction: column; gap: 2px; cursor: pointer;
border: 1px solid var(--border-primary); border-radius: 6px; padding: 6px 8px;
}
.mode.on { border-color: var(--accent, #2563eb); background: var(--bg-overlay); }
.mode input { display: none; }
.m-t { font-size: 12px; font-weight: 700; }
.m-d { font-size: 11px; color: var(--text-secondary); line-height: 1.3; }
.warn {
margin: 0; font-size: 11px; line-height: 1.45; color: var(--text-secondary);
background: var(--bg-overlay); border: 1px dashed var(--border-primary);
border-radius: 6px; padding: 6px 8px;
}
.err { margin: 0; font-size: 12px; color: #dc2626; line-height: 1.4; }
.err .hint { color: var(--text-secondary); }
.acts { display: flex; align-items: center; gap: 6px; }
.kbd { margin-left: auto; font-size: 10px; color: var(--text-secondary); }
</style>