Files
deerflow-code/parallel-agents-vue/src/components/ParallelAgentsPanel.vue
T
董宏凯andClaude Opus 4.8 c230f7a6c6 feat(parallel-agents): 子智能体禁派活/脚本(防模仿总控) + 面板能力完善
后端 parallel_agents.py:每个并行席位 excluded_tools 增加 agent_orchestration
(派活) 与 bash(脚本逃逸),并在任务消息里加「角色边界」护栏——根治弱模型拿到
带「总控/业务链条」语气的任务后模仿总控输出「总控调度指令…」、绕道 glob/bash
翻找派活脚本的问题。新增 /api/parallel-agents/auth/token 取 token 端点
(auth/csrf 豁免)。

parallel-agents-vue 面板:协作澄清卡片、步骤图标/步骤展示、API 与类型完善等。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:41:39 +08:00

376 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 并行多智能体面板(主组件,可嵌入)。
*
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
* 聊天记录 + 回显 + 重新分析。
*
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "@/types";
import { useParallelRun } from "@/composables/useParallelRun";
import { createTask, getTask, listModels, listTasks, updateTask, type ApiConfig, type ModelItem } from "@/api";
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
const props = withDefaults(
defineProps<{
/** 后端地址;不传用配置文件默认(config.ts 的 DEFAULT_BASE_URL)。 */
baseUrl?: string;
/** 已有 token(Bearer),与 username 二选一;都不传走配置默认地址 + 免密登录失败提示。 */
token?: string;
getToken?: () => string | Promise<string>;
/** 免密自动登录用户名(由宿主自己传)。不传 token 时用它登录换 token。 */
username?: string;
/** 仅当后端开启口令门时需要。 */
loginPassword?: string;
agents: AgentInput[];
taskId?: string;
model?: string;
dark?: boolean;
}>(),
{ dark: true },
);
const emit = defineEmits<{
(e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
}>();
const cfg = computed<ApiConfig>(() => ({
baseUrl: props.baseUrl || DEFAULT_BASE_URL,
token: props.token,
getToken: props.getToken,
username: props.username,
loginPassword: props.loginPassword,
}));
const run = useParallelRun();
const { dialogues, seats, running } = run;
const form = reactive({ taskName: "", brief: "" });
const currentTaskId = ref<string | null>(props.taskId ?? null);
const selected = ref<string[]>([]);
const saving = ref(false);
const history = ref<SavedTask[]>([]);
const showHistory = ref(false);
const errorMsg = ref<string | null>(null);
// 模型切换:拉后端模型列表,空字符串 = 用默认(prop/config/后端回退)。
const models = ref<ModelItem[]>([]);
const selectedModel = ref<string>(props.model ?? DEFAULT_MODEL ?? "");
const dialogueById = computed(() => {
const m = new Map<number, Dialogue>();
for (const d of dialogues.value) m.set(d.id, d);
return m;
});
const hasResults = computed(() => dialogues.value.length > 0);
const canStart = computed(
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
);
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
watch(
seats,
(s) => {
if (!s.length) {
selected.value = [];
return;
}
// 保留仍存在的当前选中;首次 / 失效时默认显示第 1 个席位。
const cur = selected.value[0];
const keep = cur && s.some((x) => x.agentId === cur) ? cur : s[0].agentId;
selected.value = [keep];
},
{ immediate: true },
);
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
watch(
() => props.taskId,
(id) => {
currentTaskId.value = id ?? null;
if (id) void loadTask(id);
},
);
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
function toggleSeat(agentId: string) {
selected.value = [agentId];
}
const visibleSeats = computed(() => {
const id = selected.value[0] ?? seats.value[0]?.agentId;
const seat = seats.value.find((s) => s.agentId === id);
return seat ? [seat] : [];
});
// ── 给宿主读取「每个智能体模型返回的内容」的简单方法 ──────────────────────────
function _statusOf(agentId: string, streaming?: boolean): SeatView["status"] {
return seats.value.find((s) => s.agentId === agentId)?.status ?? (streaming ? "running" : "done");
}
/** 取所有智能体的返回内容(按席位顺序)。宿主:`panelRef.value.getResults()`。 */
function getResults(): AgentResult[] {
return dialogues.value.map((d) => ({
agent_id: d.agentId,
name: d.sender,
content: d.content,
status: _statusOf(d.agentId, d.streaming),
}));
}
/** 取某个智能体的返回内容;不存在返回 null。 */
function getResult(agentId: string): AgentResult | null {
return getResults().find((r) => r.agent_id === agentId) ?? null;
}
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />` → panelRef.value.getResults()
defineExpose({ getResults, getResult, start: () => onStart(), cancel: () => run.cancel() });
/** 用户在某智能体的协作卡片里提交了回答 → 带上回答重跑该智能体。 */
async function onClarify(agentId: string, answer: string) {
errorMsg.value = null;
const brief = `${form.brief}\n\n【用户补充】${answer}`.trim();
try {
await run.rerunAgent(cfg.value, {
taskName: form.taskName,
brief,
agentId,
model: selectedModel.value || undefined,
});
emit("completed", getResults());
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function onStart() {
if (!canStart.value) return;
errorMsg.value = null;
try {
await run.start(cfg.value, {
taskName: form.taskName,
brief: form.brief,
agents: props.agents,
model: selectedModel.value || undefined,
});
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function onSave() {
if (saving.value || !hasResults.value) return;
saving.value = true;
errorMsg.value = null;
try {
const title = form.taskName.trim() || "未命名任务";
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a }));
const payload = { title, brief: form.brief, agents: agentsSnapshot, messages: dialogues.value };
// 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。
let saved: SavedTask | null;
const id = currentTaskId.value;
if (id) {
saved = await updateTask(cfg.value, id, payload);
if (!saved) saved = await createTask(cfg.value, { id, ...payload });
} else {
saved = await createTask(cfg.value, payload);
}
if (saved) {
currentTaskId.value = saved.id;
emit("update:taskId", saved.id);
emit("saved", saved);
await refreshHistory();
}
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
async function refreshHistory() {
try {
history.value = await listTasks(cfg.value);
} catch {
/* 历史拉取失败不阻断主流程 */
}
}
async function loadTask(id: string) {
showHistory.value = false;
errorMsg.value = null;
try {
const task = await getTask(cfg.value, id);
if (!task) return;
currentTaskId.value = task.id;
form.taskName = task.title ?? "";
form.brief = task.brief ?? "";
selected.value = [];
run.hydrate(task.messages ?? []);
emit("update:taskId", task.id);
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function refreshModels() {
const list = await listModels(cfg.value);
models.value = list;
// 当前选择不在列表里(且非空)时,回退为默认(空 = 后端回退)。
if (selectedModel.value && !list.some((m) => m.name === selectedModel.value)) {
selectedModel.value = "";
}
}
onMounted(() => {
void refreshHistory();
void refreshModels();
if (props.taskId) void loadTask(props.taskId);
});
</script>
<template>
<div
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
:class="dark ? 'parallel-agents--dark' : ''"
>
<!-- 顶部:任务名 + 操作 -->
<div class="pa-border flex shrink-0 flex-col gap-2 border-b px-4 py-3">
<div class="flex items-center gap-2">
<input
v-model="form.taskName"
class="pa-input min-w-0 flex-1 px-3 py-1.5 text-sm"
placeholder="任务名称"
/>
<div class="relative shrink-0">
<button
type="button"
class="pa-btn-ghost px-2.5 py-1.5 text-xs"
@click="showHistory = !showHistory"
>
历史 ▾
</button>
<div
v-if="showHistory"
class="pa-card pa-border absolute right-0 z-20 mt-1 max-h-72 w-72 overflow-auto rounded-lg border p-1 shadow-lg"
>
<div v-if="!history.length" class="px-3 py-4 text-center text-xs pa-text-muted">
暂无保存的任务
</div>
<button
v-for="t in history"
:key="t.id"
type="button"
class="pa-btn-ghost block w-full truncate px-2.5 py-1.5 text-left text-xs"
style="border: 0"
@click="loadTask(t.id)"
>
<span class="font-semibold">{{ t.title || "未命名任务" }}</span>
<span class="pa-text-muted"> · {{ (t.updated_at || "").slice(0, 16).replace("T", " ") }}</span>
</button>
</div>
</div>
</div>
<textarea
v-model="form.brief"
rows="2"
class="pa-input w-full resize-y px-3 py-1.5 text-sm"
placeholder="任务简介:把背景、目标、约束写清楚,会并行下发给各智能体"
/>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="!running"
type="button"
class="pa-btn-primary px-3.5 py-1.5 text-sm font-semibold"
:disabled="!canStart"
@click="onStart"
>
{{ hasResults ? "重新分析" : "开始分析" }}
</button>
<button
v-else
type="button"
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
@click="run.cancel()"
>
停止
</button>
<button
type="button"
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
:disabled="saving || !hasResults"
@click="onSave"
>
{{ saving ? "保存中…" : currentTaskId ? "更新保存" : "保存" }}
</button>
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
<label class="ml-auto flex items-center gap-1.5 text-xs pa-text-muted">
模型
<select
v-model="selectedModel"
:disabled="running"
class="pa-input px-2 py-1 text-xs"
title="选择本次并行分析使用的模型"
>
<option value="">默认</option>
<option v-for="m in models" :key="m.name" :value="m.name">
{{ m.display_name || m.name }}
</option>
</select>
</label>
<span v-if="props.agents.length === 0" class="text-xs pa-text-muted">
⚠️ 未传入任何智能体(agents 为空)
</span>
</div>
<p v-if="errorMsg" class="text-xs" style="color: var(--pa-rose)">{{ errorMsg }}</p>
</div>
<!-- 席位状态条 -->
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
</div>
<!-- 会话区:并行双列 -->
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<div
v-if="hasResults"
class="grid gap-4"
:class="visibleSeats.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
>
<div
v-for="seat in visibleSeats"
:key="seat.agentId"
class="pa-surface-soft pa-border flex min-w-0 flex-col rounded-xl border p-3"
>
<MessageBubble
v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!"
:submitting="running"
@clarify="onClarify"
/>
<div v-else class="py-6 text-center text-xs pa-text-muted">
{{ seat.name }} · 等待输出…
</div>
</div>
</div>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-2 text-center"
>
<p class="text-sm pa-text-muted">填写任务名称与简介,点「开始分析」并行调用各智能体。</p>
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
</div>
</div>
</div>
</template>