ReplanOrgGraphButton.vue
6.87 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
<template>
<div class="replan" :class="[variant, phase]">
<button class="rp-btn" :disabled="busy" @click="onClick">
<template v-if="phase === 'posting'">⏳ 正在触发…</template>
<template v-else-if="phase === 'planning'">⏳ 规划中 {{ mmss }}</template>
<template v-else>🧭 {{ hasGraph ? '重新规划组织' : '规划组织结构' }}</template>
</button>
<!-- 过渡态的说明文字:fire-and-forget 的调用点完没反应最容易被当成没点上 -->
<p v-if="note" class="rp-note" :class="noteKind">
{{ note }}
<button v-if="phase === 'timeout'" class="rp-link" @click="waitMore">继续等</button>
</p>
</div>
</template>
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue';
/**
* 「重新规划组织」入口(R-18.1 / 27-P3B §四 W8)。
*
* 用户原话:「人工的操作不应该是跑脚本,而是我点击 org graph planning,让他来真实运行,
* 并且 plan 出来需要雇佣哪几个 agent 到 company」。后端 `POST /:companyId/org-graph/replan`
* 从 P2 起就在(company.js),但在此之前**全仓库没有任何前端调用方**,只能 curl。
*
* 三件事这个组件全包了,因为两个页面(GraphConsole 左栏、CompanyDashboard 组织流程块)
* 要的是同一套行为,只是大小不同:
* 1. 触发:POST replan(服务端 hire orggraph-planning + fire-and-forget 起一轮对话)
* 2. 过渡态:按钮转「规划中 mm:ss」并禁用 —— agent 一轮是分钟级的,没有过渡态用户会连点
* 3. 刷新:轮询 GET org-graph,图变了就 emit('planned'),由父组件重载自己的视图
*
* **为什么是轮询不是 SSE**:replan 端点内部起的那条 agent 对话 SSE 由服务端自己消费掉了
* (company.js triggerOrgGraphPlanning 的 fetch),浏览器手里没有那条流的把手,端点也不回
* conversationId。控制台现成的 SSE(/graph-runs/:id/stream)是 graph run 的事件轨,org graph
* 规划不产生 graph run。所以能观测到「规划完成」的唯一信号就是 org graph 本身变了。
*/
const props = defineProps({
companyId: { type: String, required: true },
/** 已有图时按钮文案改成「重新规划」,并在点击前确认一次:writeGraph 是整图覆盖 */
hasGraph: { type: Boolean, default: false },
/** primary = 空态里的大按钮;inline = 已有图时挂在标题栏的小按钮 */
variant: { type: String, default: 'inline' },
});
const emit = defineEmits(['planned']);
const POLL_MS = 4000;
const WAIT_MS = 6 * 60 * 1000; // 一轮 orggraph-planning 的墙钟量级是分钟;超时不代表失败,只代表别再干等
const phase = ref('idle'); // idle | posting | planning | done | timeout | error
const note = ref('');
const startedAt = ref(0);
const now = ref(Date.now());
let poller = null;
let ticker = null;
let baseline = '';
const busy = computed(() => phase.value === 'posting' || phase.value === 'planning');
const noteKind = computed(() => (phase.value === 'error' ? 'bad' : phase.value === 'done' ? 'ok' : ''));
const mmss = computed(() => {
const s = Math.max(0, Math.floor((now.value - startedAt.value) / 1000));
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
});
onBeforeUnmount(stopTimers);
function stopTimers() {
clearInterval(poller); poller = null;
clearInterval(ticker); ticker = null;
}
/**
* 图的指纹。节点 id 也进指纹:writeGraph 是「旧行整体置 removed + 插新行」,
* 即便重新规划出的形状与原来一模一样,节点 id 也会全变——只比形状会把「重规划出同一张图」
* 误判成超时。
*/
async function fetchSig() {
const r = await fetch(`/api/company/${props.companyId}/org-graph`);
if (!r.ok) throw new Error(`org-graph HTTP ${r.status}`);
const g = await r.json();
return JSON.stringify([
(g.nodes || []).map((n) => [n.id, n.roleName, n.systemAgentId]),
(g.edges || []).map((e) => [e.from, e.to, e.condition, e.failReason ?? null, e.edgeKind]),
]);
}
async function onClick() {
if (busy.value) return;
if (props.hasGraph && !window.confirm(
'重新规划会让 orggraph-planning 重画整张 org graph,并覆盖现有的节点与边(旧图作废)。继续?'
)) return;
phase.value = 'posting';
note.value = '';
try {
// 先取基线,再 POST:反过来的话规划快到抢在取基线之前完成,就永远比不出变化
baseline = await fetchSig().catch(() => '');
const r = await fetch(`/api/company/${props.companyId}/org-graph/replan`, { method: 'POST' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${r.status}`);
}
} catch (e) {
phase.value = 'error';
note.value = `触发失败:${e.message}`;
return;
}
startWaiting();
}
function startWaiting() {
phase.value = 'planning';
startedAt.value = Date.now();
now.value = Date.now();
note.value = 'orggraph-planning 已启动:读公司目标 → 选骨架 → 落图 → 按拓扑序招人。这一轮是分钟级的,图出现后本页会自动刷新。';
stopTimers();
ticker = setInterval(() => { now.value = Date.now(); }, 1000);
poller = setInterval(poll, POLL_MS);
}
/** 超时不是失败判定,只是停止干等;再点一下继续轮询(基线不重置,之前的变化照样算数) */
function waitMore() { startWaiting(); }
async function poll() {
let sig;
try {
sig = await fetchSig();
} catch {
return; // 单次轮询失败(后端重启等)不打断,等下一拍
}
if (sig !== baseline) {
stopTimers();
phase.value = 'done';
note.value = '✅ 组织流程已生成,已刷新。';
emit('planned');
return;
}
if (Date.now() - startedAt.value > WAIT_MS) {
stopTimers();
phase.value = 'timeout';
note.value = '等了 6 分钟还没看到新图。规划可能仍在进行,也可能失败了(同一 agent 不允许并发会话,上一轮没结束时这次触发会被拒)。去「团队成员 → orggraph-planning」的对话里看它跑到哪一步了。';
}
}
</script>
<style scoped>
.replan { display: inline-flex; flex-direction: column; gap: 6px; min-width: 0; }
.replan.primary { display: flex; align-items: center; }
.rp-btn {
cursor: pointer; border-radius: 6px; border: 1px solid #2b6cb0;
background: #2b6cb0; color: #fff; font-size: 12px; padding: 5px 12px;
white-space: nowrap;
}
.rp-btn:disabled { cursor: default; opacity: .6; }
.replan.inline .rp-btn { background: #fff; color: #2b6cb0; padding: 3px 9px; font-size: 11px; }
.rp-note { margin: 0; font-size: 11px; line-height: 1.7; color: #777; max-width: 420px; }
.rp-note.ok { color: #2f855a; }
.rp-note.bad { color: #c53030; }
.rp-link {
border: none; background: none; padding: 0 0 0 4px; cursor: pointer;
color: #2b6cb0; font-size: 11px; text-decoration: underline;
}
</style>