Skip to content

Commit 8a2c89c

Browse files
authored
feat: support multi-agent Goal Channels (#3969)
* feat: support multi-agent goal channels Signed-off-by: huangruiteng <huangrt01@163.com> * fix(dashboard): expose registered agents to goal channels Signed-off-by: huangruiteng <huangrt01@163.com> * feat(goal-channel): connect all registered agents Signed-off-by: huangruiteng <huangrt01@163.com> * fix(goal-channel): clean up agent inbox on disconnect Signed-off-by: huangruiteng <huangrt01@163.com> * build(dashboard): refresh packaged Goal Channel UI Signed-off-by: huangruiteng <huangrt01@163.com> --------- Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 91111fc commit 8a2c89c

23 files changed

Lines changed: 2000 additions & 462 deletions

‎apps/presentation/dashboard/src/data/chat.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,7 @@ export async function fetchLarkGroupChats(appRef: string, query?: string) {
15271527

15281528
export type LarkGoalConnection = {
15291529
agent_id: string | null;
1530+
connection_id: string;
15301531
app_label: string;
15311532
app_ref: string;
15321533
capture_scope: LarkCaptureScope;
@@ -1556,6 +1557,7 @@ const larkConnectionsSchema = z.object({
15561557
ok: z.literal(true),
15571558
connections: z.array(z.object({
15581559
agent_id: z.string().nullable().default(null),
1560+
connection_id: z.string(),
15591561
app_label: z.string(),
15601562
app_ref: z.string(),
15611563
capture_scope: z.enum(["addressed_only", "configured_chat_all"]).default("addressed_only"),
@@ -1629,9 +1631,10 @@ export async function connectLarkGoalTopic(options: {
16291631
);
16301632
}
16311633

1632-
export async function disconnectLarkGoalTopic(goalId: string) {
1634+
export async function disconnectLarkGoalTopic(goalId: string, connectionId: string) {
1635+
const params = new URLSearchParams({ goal_id: goalId, connection_id: connectionId });
16331636
return goalChannelOperationSchema.parse(
1634-
await requestJson<unknown>(`/api/chat/lark/connections?goal_id=${encodeURIComponent(goalId)}`, {
1637+
await requestJson<unknown>(`/api/chat/lark/connections?${params.toString()}`, {
16351638
method: "DELETE",
16361639
}),
16371640
);

‎apps/presentation/dashboard/src/data/status.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,10 @@ export const runGoalSchema = z.object({
493493
control_plane: controlPlaneSchema.optional().nullable(),
494494
spawn_policy: orchestrationPolicySchema.optional().nullable(),
495495
orchestration: orchestrationPolicySchema.optional().nullable(),
496+
coordination: z.object({
497+
agent_model: z.string().optional().nullable(),
498+
registered_agents: z.array(z.string()).optional().default([]),
499+
}).optional().nullable(),
496500
index_exists: z.boolean().optional().default(false),
497501
raw_index_records: z.number().optional().default(0),
498502
unique_runs: z.number().optional().default(0),

‎apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ const en = {
382382
"lark.autoReplyReady": "Auto reply ready",
383383
"lark.bindGoal": "Bind to Goal",
384384
"lark.cancel": "Cancel",
385-
"lark.cardinality": "One Lark App · many Goals · one topic per Goal",
385+
"lark.cardinality": "One Lark App · many Goals · one isolated route per Agent",
386386
"lark.capture": "Capture",
387387
"lark.captureAddressed": "Only messages that mention or reply to the App",
388388
"lark.captureAll": "All messages in this Goal topic",
@@ -392,6 +392,9 @@ const en = {
392392
"lark.closeCreate": "Close create dialog",
393393
"lark.closeSettings": "Close Lark settings",
394394
"lark.connect": "Connect",
395+
"lark.connectAllAgents": "Connect every registered Agent",
396+
"lark.connectAllAgentsAction": "Connect {count} Agents",
397+
"lark.connectAllAgentsDescription": "Create {count} isolated routes in one guided action. Each Agent keeps its own connection identity and inbox.",
395398
"lark.connectApp": "Connect Lark App",
396399
"lark.connection": "Connection",
397400
"lark.connections": "Connections",
@@ -476,7 +479,10 @@ const en = {
476479
"lark.setupCopy": "LoopX opens the official Feishu creation page through lark-cli. App credentials stay in the system Keychain; LoopX stores only the profile reference.",
477480
"lark.someoneMentions": "Someone mentions the Agent",
478481
"lark.targetAgent": "Target Agent",
479-
"lark.targetAgentDescription": "Messages can only go to the Agent registered for this Goal. This selection grants no new permissions. Steering and Queuing bind to the Goal's exact current Session.",
482+
"lark.targetAgentDescription": "Choose a registered Agent in this Goal. This connection delivers to one Agent; it does not broadcast or grant permissions. Steering and Queuing require that Agent's exact active Session.",
483+
"lark.agentUnavailable": "{agent} · no longer available",
484+
"lark.selectRegisteredAgent": "Select an available registered Agent before connecting. The previous Agent will not be replaced automatically.",
485+
"lark.error.agentConflict": "This Goal already has a connection for another Agent. It was preserved. Multiple Agent connections are not supported yet; disconnect the existing route only if you intend to replace it.",
480486
"lark.topicPreview": "Topic preview",
481487
"lark.topicReply": "Topic reply",
482488
"lark.trigger": "Trigger",
@@ -1202,7 +1208,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
12021208
"lark.autoReplyReady": "自动回复可用",
12031209
"lark.bindGoal": "绑定到 Goal",
12041210
"lark.cancel": "取消",
1205-
"lark.cardinality": "一个 Lark App · 多个 Goal · 每个 Goal 一个 Topic",
1211+
"lark.cardinality": "一个 Lark App · 多个 Goal · 每个 Agent 一条隔离路由",
12061212
"lark.capture": "接收范围",
12071213
"lark.captureAddressed": "仅接收提及 App 或回复 App 的消息",
12081214
"lark.captureAll": "接收此 Goal Topic 中的所有消息",
@@ -1212,6 +1218,9 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
12121218
"lark.closeCreate": "关闭创建弹窗",
12131219
"lark.closeSettings": "关闭 Lark 设置",
12141220
"lark.connect": "连接",
1221+
"lark.connectAllAgents": "连接全部已注册 Agent",
1222+
"lark.connectAllAgentsAction": "一键连接 {count} 个 Agent",
1223+
"lark.connectAllAgentsDescription": "一次引导创建 {count} 条隔离路由;每个 Agent 保留独立连接身份和收件箱。",
12151224
"lark.connectApp": "连接 Lark App",
12161225
"lark.connection": "连接",
12171226
"lark.connections": "连接",
@@ -1296,7 +1305,10 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
12961305
"lark.setupCopy": "LoopX 将通过 lark-cli 打开飞书官方创建页面。应用凭证保存在系统 Keychain,LoopX 只保留 profile 引用。",
12971306
"lark.someoneMentions": "有人提及 Agent",
12981307
"lark.targetAgent": "目标 Agent",
1299-
"lark.targetAgentDescription": "只能投递给当前 Goal 已注册的 Agent;此选择不授予新权限。实时引导与排队会绑定该 Goal 当前的精确 Session。",
1308+
"lark.targetAgentDescription": "选择该 Goal 内已注册的 Agent。此连接只投递给一个 Agent,不广播、不授予新权限。实时引导与排队需要该 Agent 的精确活跃 Session。",
1309+
"lark.agentUnavailable": "{agent} · 已不可用",
1310+
"lark.selectRegisteredAgent": "请先选择可用的已注册 Agent;不会自动替换原先的接收者。",
1311+
"lark.error.agentConflict": "该 Goal 已连接另一个 Agent,原路由已保留。目前尚不支持多个 Agent 连接;只有确定要替换时,才请先断开原连接。",
13001312
"lark.topicPreview": "Topic 预览",
13011313
"lark.topicReply": "Topic 回复",
13021314
"lark.trigger": "触发方式",

‎apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx‎

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ function larkErrorMessage(cause: unknown, fallback: string, t: WorkspaceTranslat
145145
lark_app_required: t("lark.error.appRequired"),
146146
invalid_lark_app: t("lark.error.invalidApp"),
147147
lark_group_lookup_failed: t("lark.error.groupLookup"),
148+
goal_topic_agent_conflict: t("lark.error.agentConflict"),
148149
provider_api_failed: t("lark.error.provider"),
149150
};
150151
return messages[code] ?? cause.message;
@@ -186,10 +187,11 @@ export function LarkSettingsPage({
186187
const [ingressMode, setIngressMode] = useState<LarkIngressMode>("async_inbox");
187188
const [replyMode, setReplyMode] = useState<LarkReplyMode>("topic_reply");
188189
const [agentId, setAgentId] = useState("");
190+
const [connectAllAgents, setConnectAllAgents] = useState(false);
189191
const [connecting, setConnecting] = useState(false);
190192
const [connectError, setConnectError] = useState<string | null>(null);
191193
const [editingGoalId, setEditingGoalId] = useState<string | null>(null);
192-
const [disconnectGoalId, setDisconnectGoalId] = useState<string | null>(null);
194+
const [disconnectConnectionId, setDisconnectConnectionId] = useState<string | null>(null);
193195
const [setupOpen, setSetupOpen] = useState(false);
194196
const [setupAppRef, setSetupAppRef] = useState("loopx-workspace-bot");
195197
const [setupBrand, setSetupBrand] = useState<"feishu" | "lark">("feishu");
@@ -299,6 +301,19 @@ export function LarkSettingsPage({
299301
}, [setupOpen, setupSnapshot]);
300302

301303
const selectedGoal = goals.find((goal) => goal.goalId === goalId);
304+
const fallbackGoalAgents = selectedGoal?.agentId
305+
? [{ agentId: selectedGoal.agentId, label: selectedGoal.agentLabel ?? selectedGoal.agentId }]
306+
: [];
307+
const goalAgents = selectedGoal?.agentLanes?.length
308+
? selectedGoal.agentLanes
309+
: fallbackGoalAgents;
310+
const selectedAgentAvailable = goalAgents.some((agent) => agent.agentId === agentId);
311+
let targetAgentIds: string[] = [];
312+
if (connectAllAgents) targetAgentIds = goalAgents.map((agent) => agent.agentId);
313+
else if (selectedAgentAvailable) targetAgentIds = [agentId];
314+
let connectActionLabel = t("lark.connect");
315+
if (editingGoalId) connectActionLabel = t("lark.saveConnection");
316+
else if (connectAllAgents) connectActionLabel = t("lark.connectAllAgentsAction", { count: targetAgentIds.length });
302317
const selectedApp = apps.find((app) => app.app_ref === appRef);
303318
const selectedChat = chats.find((chat) => chat.chat_id === chatId);
304319
const filteredConnections = useMemo(() => {
@@ -317,6 +332,7 @@ export function LarkSettingsPage({
317332
setEditingGoalId(null);
318333
setGoalId(nextGoal?.goalId ?? "");
319334
setAgentId(nextGoal?.agentId ?? "");
335+
setConnectAllAgents(false);
320336
setCaptureScope("addressed_only");
321337
setIngressMode("async_inbox");
322338
setReplyMode("topic_reply");
@@ -330,6 +346,7 @@ export function LarkSettingsPage({
330346
setAppRef(connection.app_ref);
331347
setGoalId(connection.goal_id);
332348
setAgentId(connection.agent_id ?? goals.find((goal) => goal.goalId === connection.goal_id)?.agentId ?? "");
349+
setConnectAllAgents(false);
333350
setCaptureScope(connection.capture_scope);
334351
setIngressMode(connection.ingress_mode === "direct_session" ? "session_queue" : connection.ingress_mode);
335352
setReplyMode(connection.reply_mode);
@@ -376,12 +393,11 @@ export function LarkSettingsPage({
376393
}
377394

378395
async function connect() {
379-
if (!appRef || !goalId || !selectedChat || connecting) return;
396+
if (!appRef || !goalId || !selectedChat || targetAgentIds.length === 0 || connecting) return;
380397
setConnecting(true);
381398
setConnectError(null);
382399
try {
383400
const input = {
384-
agentId,
385401
appRef,
386402
captureScope,
387403
chatId: selectedChat.chat_id,
@@ -391,10 +407,14 @@ export function LarkSettingsPage({
391407
ingressMode,
392408
replyMode,
393409
} as const;
394-
const preview = await connectLarkGoalTopic({ ...input, execute: false });
395-
if (!preview.ok) throw new ChatApiError(preview.public_summary ?? preview.blocker ?? t("lark.error.bindPreview"), { error_code: preview.blocker ?? "provider_api_failed" });
396-
const result = await connectLarkGoalTopic({ ...input, execute: true });
397-
if (!result.ok) throw new ChatApiError(result.public_summary ?? result.blocker ?? t("lark.error.bind"), { error_code: result.blocker ?? "provider_api_failed" });
410+
for (const targetAgentId of targetAgentIds) {
411+
const preview = await connectLarkGoalTopic({ ...input, agentId: targetAgentId, execute: false });
412+
if (!preview.ok) throw new ChatApiError(preview.public_summary ?? preview.blocker ?? t("lark.error.bindPreview"), { error_code: preview.blocker ?? "provider_api_failed" });
413+
}
414+
for (const targetAgentId of targetAgentIds) {
415+
const result = await connectLarkGoalTopic({ ...input, agentId: targetAgentId, execute: true });
416+
if (!result.ok) throw new ChatApiError(result.public_summary ?? result.blocker ?? t("lark.error.bind"), { error_code: result.blocker ?? "provider_api_failed" });
417+
}
398418
setModalOpen(false);
399419
await refresh();
400420
onChanged?.();
@@ -405,14 +425,14 @@ export function LarkSettingsPage({
405425
}
406426
}
407427

408-
async function disconnect(goal: string) {
409-
if (disconnectGoalId !== goal) {
410-
setDisconnectGoalId(goal);
428+
async function disconnect(goal: string, connectionId: string) {
429+
if (disconnectConnectionId !== connectionId) {
430+
setDisconnectConnectionId(connectionId);
411431
return;
412432
}
413433
try {
414-
await disconnectLarkGoalTopic(goal);
415-
setDisconnectGoalId(null);
434+
await disconnectLarkGoalTopic(goal, connectionId);
435+
setDisconnectConnectionId(null);
416436
await refresh();
417437
onChanged?.();
418438
} catch (cause) {
@@ -466,7 +486,7 @@ export function LarkSettingsPage({
466486
<div className="personal-lark-table" role="table" aria-label={t("lark.goalTopicConnections")}>
467487
<div className="personal-lark-table-head" role="row"><span>{t("lark.connection")}</span><span>{t("common.goal")}</span><span>{t("lark.capture")}</span><span>{t("lark.processing")}</span><span>{t("common.actions")}</span></div>
468488
{filteredConnections.map((connection) => (
469-
<div className="personal-lark-table-row" key={connection.goal_id} role="row">
489+
<div className="personal-lark-table-row" key={connection.connection_id} role="row">
470490
<span>
471491
<strong>{connection.chat_name}</strong>
472492
<small>{connection.app_label} · {larkConnectionHealth(connection, t).label}</small>
@@ -483,7 +503,7 @@ export function LarkSettingsPage({
483503
<span><strong>{ingressPresentation(connection.ingress_mode, t).label}</strong><small>{connection.agent_id ?? ingressPresentation(connection.ingress_mode, t).detail}</small></span>
484504
<span className="personal-lark-row-actions">
485505
<button aria-label={t("lark.settingsConfigure", { goal: connection.goal_title })} onClick={() => openConnectionEditor(connection)} type="button"><Settings2 size={15} /></button>
486-
<button aria-label={t("lark.settingsDisconnect", { goal: connection.goal_title })} className={disconnectGoalId === connection.goal_id ? "is-confirm" : ""} onClick={() => void disconnect(connection.goal_id)} type="button"><Unlink size={15} />{disconnectGoalId === connection.goal_id ? t("common.confirm") : null}</button>
506+
<button aria-label={t("lark.settingsDisconnect", { goal: connection.goal_title })} className={disconnectConnectionId === connection.connection_id ? "is-confirm" : ""} onClick={() => void disconnect(connection.goal_id, connection.connection_id)} type="button"><Unlink size={15} />{disconnectConnectionId === connection.connection_id ? t("common.confirm") : null}</button>
487507
</span>
488508
</div>
489509
))}
@@ -511,11 +531,16 @@ export function LarkSettingsPage({
511531
<label><span>{t("lark.topicPreview")}</span><div className="personal-lark-topic-preview"><MessageSquareText size={15} /># {selectedGoal?.title ?? selectedGoal?.goalId ?? "Goal"}</div></label>
512532
<label><span>{t("lark.captureScope")}</span><select aria-label={t("lark.captureScope")} onChange={(event) => setCaptureScope(event.target.value as LarkCaptureScope)} value={captureScope}><option value="addressed_only">{t("lark.captureAddressed")}</option><option value="configured_chat_all">{t("lark.captureAll")}</option></select><small>{t("lark.captureScopeDescription")}</small></label>
513533
<fieldset aria-label={t("lark.agentIngress")} className="personal-lark-ingress"><legend>{t("lark.agentIngress")}</legend><div>{(["live_steering", "session_queue", "async_inbox"] as const).map((mode) => { const presentation = ingressPresentation(mode, t); return <label className={ingressMode === mode ? "is-active" : ""} key={mode}><input aria-label={presentation.label} checked={ingressMode === mode} name="lark-agent-ingress" onChange={() => setIngressMode(mode)} type="radio" value={mode} /><span><strong>{presentation.label}</strong><small>{presentation.detail}</small></span></label>; })}</div></fieldset>
514-
<label><span>{t("lark.targetAgent")}</span><select aria-label={t("lark.targetAgent")} onChange={(event) => setAgentId(event.target.value)} value={agentId}><option value={selectedGoal?.agentId ?? ""}>{selectedGoal?.agentLabel ?? selectedGoal?.agentId ?? t("lark.noAgentConfigured")}</option></select><small>{t("lark.targetAgentDescription")}</small></label>
534+
<label><span>{t("lark.targetAgent")}</span><select aria-label={t("lark.targetAgent")} onChange={(event) => setAgentId(event.target.value)} value={agentId}>
535+
{!selectedAgentAvailable ? <option disabled value={agentId}>{agentId ? t("lark.agentUnavailable", { agent: agentId }) : t("lark.noAgentConfigured")}</option> : null}
536+
{goalAgents.map((agent) => <option key={agent.agentId} value={agent.agentId}>{agent.label === agent.agentId ? agent.agentId : `${agent.label} · ${agent.agentId}`}</option>)}
537+
</select><small>{t("lark.targetAgentDescription")}</small></label>
538+
{!editingGoalId && goalAgents.length > 1 ? <label aria-label={t("lark.connectAllAgents")} className="personal-lark-check"><input checked={connectAllAgents} onChange={(event) => setConnectAllAgents(event.target.checked)} type="checkbox" /><span><strong>{t("lark.connectAllAgents")}</strong><small>{t("lark.connectAllAgentsDescription", { count: goalAgents.length })}</small></span></label> : null}
539+
{targetAgentIds.length === 0 ? <p className="personal-notification-error" role="alert">{t("lark.selectRegisteredAgent")}</p> : null}
515540
<label><span>{t("lark.replyMode")}</span><select aria-label={t("lark.replyMode")} onChange={(event) => setReplyMode(event.target.value as LarkReplyMode)} value={replyMode}><option value="topic_reply">{t("lark.topicReply")}</option></select><small>{t("lark.replyModeDescription")}</small></label>
516541
<p className="personal-lark-cardinality"><Check size={15} />{t("lark.cardinality")}</p>
517-
{connectError ? <p className="personal-notification-error">{connectError}</p> : null}
518-
<footer><button className="personal-secondary-action" onClick={() => setModalOpen(false)} type="button">{t("lark.cancel")}</button><button className="personal-primary-action" disabled={loading || !appRef || !selectedApp?.reply_ready || !goalId || !chatId || !agentId || connecting} onClick={() => void connect()} type="button">{connecting ? <Loader2 className="is-spinning" size={15} /> : null}{editingGoalId ? t("lark.saveConnection") : t("lark.connect")}</button></footer>
542+
{connectError ? <p className="personal-notification-error" role="alert">{connectError}</p> : null}
543+
<footer><button className="personal-secondary-action" onClick={() => setModalOpen(false)} type="button">{t("lark.cancel")}</button><button className="personal-primary-action" disabled={loading || !appRef || !selectedApp?.reply_ready || !goalId || !chatId || targetAgentIds.length === 0 || connecting} onClick={() => void connect()} type="button">{connecting ? <Loader2 className="is-spinning" size={15} /> : null}{connectActionLabel}</button></footer>
519544
</section>
520545
</div>
521546
) : null}

0 commit comments

Comments
 (0)