后端 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>
376 lines
13 KiB
Vue
376 lines
13 KiB
Vue
<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>
|