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>
This commit is contained in:
1 parent
3405795371
commit
c230f7a6c6
18 files changed
+1276
-128
No files matched your search
@@ -2,19 +2,29 @@
|
||||
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
|
||||
import { reactive, ref } from "vue";
|
||||
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
|
||||
import type { AgentInput } from "./types";
|
||||
import type { AgentInput, AgentResult } from "./types";
|
||||
|
||||
import { DEFAULT_BASE_URL } from "./config";
|
||||
|
||||
const cfg = reactive({
|
||||
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
|
||||
username: localStorage.getItem("pa.username") || "demo",
|
||||
taskId: localStorage.getItem("pa.taskId") || "",
|
||||
agentsRaw: localStorage.getItem("pa.agents") || "",
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
const agents = ref<AgentInput[]>([]);
|
||||
|
||||
// 面板保存 / 生成 id 时回填,方便复制下来下次填进去测「按 taskId 回显」。
|
||||
function onTaskId(id: string) {
|
||||
cfg.taskId = id;
|
||||
localStorage.setItem("pa.taskId", id);
|
||||
}
|
||||
function onCompleted(results: AgentResult[]) {
|
||||
console.log("[demo] completed → 各智能体返回内容:", results);
|
||||
}
|
||||
|
||||
function parseAgents(): AgentInput[] {
|
||||
// 每行一个:`agent_id, 显示名` 或仅 `agent_id`。
|
||||
return cfg.agentsRaw
|
||||
@@ -30,6 +40,7 @@ function parseAgents(): AgentInput[] {
|
||||
function launch() {
|
||||
localStorage.setItem("pa.baseUrl", cfg.baseUrl);
|
||||
localStorage.setItem("pa.username", cfg.username);
|
||||
localStorage.setItem("pa.taskId", cfg.taskId);
|
||||
localStorage.setItem("pa.agents", cfg.agentsRaw);
|
||||
agents.value = parseAgents();
|
||||
open.value = true;
|
||||
@@ -46,8 +57,11 @@ function launch() {
|
||||
<label>用户名(免密自动登录,由你自己传)
|
||||
<input v-model="cfg.username" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<label>taskId(存储主键,留空则首次保存自动生成;填已存在的 id 会自动回显)
|
||||
<input v-model="cfg.taskId" placeholder="例如 my-task-001,留空也行" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<label>智能体(每行一个:agent_id, 显示名)
|
||||
<textarea v-model="cfg.agentsRaw" rows="4" placeholder="roundtable-intelligence, 情报收集 roundtable-risk-review, 风险审查" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
<textarea v-model="cfg.agentsRaw" rows="4" placeholder="roundtable-solution-design, 方案设计 roundtable-risk-review, 风险审查" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
|
||||
</label>
|
||||
<button @click="launch" style="align-self: flex-start; padding: 8px 16px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer">
|
||||
打开面板(弹窗内)
|
||||
@@ -64,7 +78,10 @@ function launch() {
|
||||
<ParallelAgentsPanel
|
||||
:base-url="cfg.baseUrl"
|
||||
:username="cfg.username || undefined"
|
||||
:task-id="cfg.taskId || undefined"
|
||||
:agents="agents"
|
||||
@update:taskId="onTaskId"
|
||||
@completed="onCompleted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
|
||||
*/
|
||||
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
|
||||
import { USERNAME_LOGIN_PATH } from "./config";
|
||||
import { TOKEN_PATH } from "./config";
|
||||
|
||||
export interface ApiConfig {
|
||||
baseUrl: string;
|
||||
@@ -30,12 +30,10 @@ function base(cfg: ApiConfig): string {
|
||||
const _tokenCache = new Map<string, string>();
|
||||
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
|
||||
|
||||
/** 用 username 走公共「用户名登录」端点换 access_token(CSRF 豁免)。 */
|
||||
/** 用 username 走本面板**专用**的「绕过常规登录」公共端点换 access_token(CSRF 豁免)。 */
|
||||
async function loginByUsername(cfg: ApiConfig): Promise<string> {
|
||||
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
|
||||
let url = `${base(cfg)}${USERNAME_LOGIN_PATH}`;
|
||||
if (cfg.loginPassword) url += `?password=${encodeURIComponent(cfg.loginPassword)}`;
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(`${base(cfg)}${TOKEN_PATH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: cfg.username }),
|
||||
@@ -43,7 +41,7 @@ async function loginByUsername(cfg: ApiConfig): Promise<string> {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`username login failed (${res.status}): ${text}`);
|
||||
throw new Error(`get token failed (${res.status}): ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as { access_token: string; token_type?: string };
|
||||
const token = data.access_token;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 协作 / 澄清卡片 —— 对齐圆桌第一步的 IntentClarificationCard 样式与交互:
|
||||
* 「需要你的确认」标题 + 类型标签 + 背景说明 + 选项 chip(单选点击即发 / 多选可勾)+
|
||||
* 自定义输入 + 发送按钮。提交时回传格式化好的回答文本。
|
||||
*/
|
||||
import { computed, reactive } from "vue";
|
||||
import type { ClarificationPayload, ClarOption } from "@/types";
|
||||
|
||||
const props = defineProps<{ payload: ClarificationPayload; submitting?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "submit", answer: string): void }>();
|
||||
|
||||
interface NormOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
function norm(opts: ClarOption[]): NormOption[] {
|
||||
return (opts || []).map((o, i) =>
|
||||
typeof o === "string"
|
||||
? { id: o, label: o }
|
||||
: { id: o.id || o.label || String(i), label: o.label || o.id || "", description: o.description },
|
||||
);
|
||||
}
|
||||
|
||||
const options = computed(() => norm(props.payload.options));
|
||||
const allowMultiple = computed(() => !!props.payload.allowMultiple);
|
||||
const allowCustom = computed(() => props.payload.allowCustom !== false);
|
||||
const deferSubmit = computed(() => allowCustom.value); // 允许自定义时不在单击即发,给机会补充
|
||||
const showSubmit = computed(() => allowMultiple.value || allowCustom.value || options.value.length === 0);
|
||||
|
||||
const sel = reactive({ ids: [] as string[], custom: "" });
|
||||
|
||||
function labelOf(id: string): string {
|
||||
return options.value.find((o) => o.id === id)?.label || id;
|
||||
}
|
||||
function buildAnswer(ids: string[]): string | null {
|
||||
const labels = ids.map(labelOf);
|
||||
const custom = sel.custom.trim();
|
||||
if (labels.length && custom) return `我选择:${labels.join("、")};补充:${custom}`;
|
||||
if (labels.length) return `我选择:${labels.join("、")}`;
|
||||
if (custom) return custom;
|
||||
return null;
|
||||
}
|
||||
|
||||
function clickOption(id: string) {
|
||||
if (props.submitting) return;
|
||||
if (!allowMultiple.value) {
|
||||
if (deferSubmit.value) {
|
||||
sel.ids = [id];
|
||||
return;
|
||||
}
|
||||
emit("submit", buildAnswer([id]) ?? labelOf(id));
|
||||
return;
|
||||
}
|
||||
sel.ids = sel.ids.includes(id) ? sel.ids.filter((x) => x !== id) : [...sel.ids, id];
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => sel.ids.length > 0 || sel.custom.trim().length > 0);
|
||||
function submit() {
|
||||
if (props.submitting || !canSubmit.value) return;
|
||||
const ans = buildAnswer(sel.ids);
|
||||
if (ans) emit("submit", ans);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-card pa-border mt-2 rounded-xl border p-3 shadow-sm">
|
||||
<!-- 头部 -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-bold uppercase tracking-wide pa-text-primary">需要你的确认</span>
|
||||
<span
|
||||
v-if="payload.clarificationType"
|
||||
class="pa-badge rounded-full"
|
||||
style="text-transform: none"
|
||||
>
|
||||
{{ payload.clarificationType }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="payload.context" class="text-[11px] italic leading-relaxed pa-text-muted">
|
||||
{{ payload.context }}
|
||||
</p>
|
||||
<div class="text-xs font-medium leading-relaxed pa-text">{{ payload.question }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 选项 -->
|
||||
<div v-if="options.length" class="mt-3 grid gap-2">
|
||||
<div class="text-[10px] pa-text-muted">
|
||||
{{ allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)" }}
|
||||
</div>
|
||||
<button
|
||||
v-for="opt in options"
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
:disabled="submitting"
|
||||
class="flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition"
|
||||
:class="
|
||||
sel.ids.includes(opt.id)
|
||||
? 'pa-opt-selected'
|
||||
: 'pa-border pa-surface-soft pa-text hover:opacity-90'
|
||||
"
|
||||
@click="clickOption(opt.id)"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span class="text-[11px] font-bold">{{ opt.label }}</span>
|
||||
<span v-if="opt.description" class="text-[10px] leading-snug" :class="sel.ids.includes(opt.id) ? '' : 'pa-text-muted'">
|
||||
{{ opt.description }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="sel.ids.includes(opt.id)" class="mt-0.5 shrink-0">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 自定义输入 -->
|
||||
<div v-if="allowCustom" class="mt-3 flex flex-col gap-2">
|
||||
<textarea
|
||||
v-model="sel.custom"
|
||||
:disabled="submitting"
|
||||
rows="2"
|
||||
class="pa-input min-h-16 resize-none px-2.5 py-1.5 text-xs"
|
||||
:placeholder="
|
||||
options.length
|
||||
? allowMultiple
|
||||
? '可选:补充说明(与所选项一起发送),或填写自定义回答'
|
||||
: '如果以上选项都不合适,可在这里输入自定义回答'
|
||||
: '请输入你的回答'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 发送 -->
|
||||
<div v-if="showSubmit" class="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="pa-btn-primary h-8 rounded-xl px-4 text-xs font-bold"
|
||||
:disabled="submitting || !canSubmit"
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitting ? "发送中…" : "发送回答" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pa-opt-selected {
|
||||
background-color: var(--pa-primary);
|
||||
color: #fff;
|
||||
border: 1px solid var(--pa-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -6,8 +6,10 @@ import type { Dialogue } from "@/types";
|
||||
import Avatar from "./Avatar.vue";
|
||||
import MessageStepsCard from "./MessageStepsCard.vue";
|
||||
import MarkdownContent from "./MarkdownContent.vue";
|
||||
import ClarificationCard from "./ClarificationCard.vue";
|
||||
|
||||
const props = defineProps<{ msg: Dialogue }>();
|
||||
const props = defineProps<{ msg: Dialogue; submitting?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "clarify", agentId: string, answer: string): void }>();
|
||||
const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
|
||||
</script>
|
||||
|
||||
@@ -45,6 +47,14 @@ const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
|
||||
<MarkdownContent v-if="hasContent" :source="msg.content" />
|
||||
<span v-if="msg.streaming && hasContent" class="pa-cursor" />
|
||||
</div>
|
||||
|
||||
<!-- 协作 / 澄清卡片(对齐圆桌第一步) -->
|
||||
<ClarificationCard
|
||||
v-if="msg.clarification"
|
||||
:payload="msg.clarification"
|
||||
:submitting="submitting"
|
||||
@submit="(answer) => emit('clarify', msg.agentId, answer)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,52 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
/** 步骤条 / 思维链卡:折叠展示该气泡运行期间的工具调用步骤(对齐 Step2 MessageStepsCard)。 */
|
||||
import { ref } from "vue";
|
||||
/**
|
||||
* 步骤条 / 思维链卡 —— 1:1 复刻圆桌 Step2 的 MessageStepsCard + ChainOfThought:
|
||||
* slate 边框卡 + 每步「图标列(带竖向连接线) + 中文文案 + 时间 + 参数子节点」。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import type { DialogueStep } from "@/types";
|
||||
import { getStepArgs, getStepDisplay } from "@/lib/step-display";
|
||||
import StepIcon from "./StepIcon.vue";
|
||||
|
||||
defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
|
||||
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
|
||||
const open = ref(true);
|
||||
|
||||
function argSummary(args?: Record<string, unknown> | null): string {
|
||||
if (!args) return "";
|
||||
const path = (args as { path?: unknown }).path;
|
||||
if (typeof path === "string") return path;
|
||||
try {
|
||||
const s = JSON.stringify(args);
|
||||
return s.length > 60 ? s.slice(0, 60) + "…" : s;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const rows = computed(() =>
|
||||
props.steps.map((s, i) => {
|
||||
const display = getStepDisplay(s.key, s.label, s.args);
|
||||
return {
|
||||
step: s,
|
||||
display,
|
||||
args: getStepArgs(s.key, s.args),
|
||||
// 最后一步在 streaming 时为「进行中」(前景色),其余为已完成(次级色)。
|
||||
active: props.streaming && i === props.steps.length - 1,
|
||||
isLast: i === props.steps.length - 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-surface pa-border mb-2 rounded-lg border p-0.5">
|
||||
<div class="pa-surface pa-border mb-2 w-full gap-1 rounded-lg border p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] font-semibold pa-text-muted"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span
|
||||
class="inline-block transition-transform"
|
||||
:class="open ? 'rotate-90' : ''"
|
||||
>▶</span
|
||||
>
|
||||
<span class="inline-block transition-transform" :class="open ? 'rotate-90' : ''">▶</span>
|
||||
<span>执行步骤({{ steps.length }})</span>
|
||||
<span v-if="streaming" class="pa-spin inline-block">◜</span>
|
||||
</button>
|
||||
<ol v-show="open" class="space-y-0.5 px-2 pb-1.5 pt-0.5">
|
||||
<li
|
||||
v-for="s in steps"
|
||||
:key="s.id"
|
||||
class="flex items-start gap-2 text-[11px] pa-text"
|
||||
|
||||
<div v-show="open" class="space-y-2 px-3 pb-2 pt-0.5">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.step.id"
|
||||
class="flex gap-2 text-sm"
|
||||
:class="row.active ? 'pa-text' : 'pa-text-muted'"
|
||||
>
|
||||
<span class="mt-1 inline-block size-1.5 shrink-0 rounded-full" style="background: var(--pa-primary)" />
|
||||
<span class="min-w-0">
|
||||
<span class="font-semibold">{{ s.label }}</span>
|
||||
<span v-if="argSummary(s.args)" class="pa-text-muted"> · {{ argSummary(s.args) }}</span>
|
||||
<span class="pa-text-muted"> · {{ s.ts }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
<!-- 图标列 + 竖向连接线 -->
|
||||
<div class="relative mt-0.5">
|
||||
<StepIcon :name="row.display.icon" :size="16" />
|
||||
<div
|
||||
v-if="!row.isLast"
|
||||
class="absolute left-1/2 -mx-px w-px"
|
||||
style="top: 26px; bottom: -8px; background: var(--pa-border)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 文案 + 时间 + 参数 -->
|
||||
<div class="min-w-0 flex-1 space-y-1 overflow-hidden">
|
||||
<div class="leading-snug">
|
||||
<span class="font-medium pa-text">{{ row.display.label }}</span>
|
||||
<span class="ml-2 font-mono text-[11px] pa-text-muted">{{ row.step.ts }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 参数子节点(按 kind 渲染,对齐 renderStepArgs) -->
|
||||
<template v-if="row.args">
|
||||
<span
|
||||
v-if="row.args.kind === 'chip'"
|
||||
class="pa-surface pa-border inline-block max-w-full truncate rounded border px-1.5 py-0.5 text-[11px] pa-text-muted"
|
||||
>
|
||||
{{ row.args.text }}
|
||||
</span>
|
||||
<pre
|
||||
v-else-if="row.args.kind === 'code'"
|
||||
class="pa-md max-h-60 overflow-auto rounded-md p-2 text-[11px]"
|
||||
><code>{{ row.args.text }}</code></pre>
|
||||
<span
|
||||
v-else-if="row.args.kind === 'text'"
|
||||
class="line-clamp-2 block text-[11px] leading-snug pa-text-muted"
|
||||
>
|
||||
{{ row.args.text }}
|
||||
</span>
|
||||
<span v-else-if="row.args.kind === 'kv'" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="p in row.args.pairs"
|
||||
:key="p.k"
|
||||
class="pa-surface pa-border inline-block rounded border px-1.5 py-0.5 text-[11px] pa-text-muted"
|
||||
>
|
||||
<span class="opacity-60">{{ p.k }}:</span> {{ p.v }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@
|
||||
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { AgentInput, Dialogue, SavedTask } from "@/types";
|
||||
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";
|
||||
@@ -38,6 +38,8 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
(e: "saved", task: SavedTask): void;
|
||||
(e: "update:taskId", id: string): void;
|
||||
/** 一轮并行分析结束时回传各智能体的返回内容。 */
|
||||
(e: "completed", results: AgentResult[]): void;
|
||||
}>();
|
||||
|
||||
const cfg = computed<ApiConfig>(() => ({
|
||||
@@ -90,6 +92,15 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
|
||||
watch(
|
||||
() => props.taskId,
|
||||
(id) => {
|
||||
currentTaskId.value = id ?? null;
|
||||
if (id) void loadTask(id);
|
||||
},
|
||||
);
|
||||
|
||||
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
|
||||
function toggleSeat(agentId: string) {
|
||||
selected.value = [agentId];
|
||||
@@ -101,6 +112,43 @@ const visibleSeats = computed(() => {
|
||||
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;
|
||||
@@ -111,6 +159,8 @@ async function onStart() {
|
||||
agents: props.agents,
|
||||
model: selectedModel.value || undefined,
|
||||
});
|
||||
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
|
||||
emit("completed", getResults());
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -123,21 +173,15 @@ async function onSave() {
|
||||
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;
|
||||
if (currentTaskId.value) {
|
||||
saved = await updateTask(cfg.value, currentTaskId.value, {
|
||||
title,
|
||||
brief: form.brief,
|
||||
agents: agentsSnapshot,
|
||||
messages: dialogues.value,
|
||||
});
|
||||
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, {
|
||||
title,
|
||||
brief: form.brief,
|
||||
agents: agentsSnapshot,
|
||||
messages: dialogues.value,
|
||||
});
|
||||
saved = await createTask(cfg.value, payload);
|
||||
}
|
||||
if (saved) {
|
||||
currentTaskId.value = saved.id;
|
||||
@@ -311,6 +355,8 @@ onMounted(() => {
|
||||
<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 }} · 等待输出…
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type { IconKey } from "@/lib/step-display";
|
||||
defineProps<{ name: IconKey; size?: number }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size || 16"
|
||||
:height="size || 16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<template v-if="name === 'search'">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'globe'">
|
||||
<circle cx="12" cy="12" r="10" /><path d="M2 12h20" />
|
||||
<path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10 15 15 0 0 1 4-10Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'folder'">
|
||||
<path
|
||||
d="M6 14l1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6A2 2 0 0 1 18.45 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="name === 'book'">
|
||||
<path d="M12 7v14" /><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pen'">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'terminal'">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="m7 11 2-2-2-2" /><path d="M11 13h4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'todo'">
|
||||
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" />
|
||||
<path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'question'">
|
||||
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" /><path d="M12 17h.01" />
|
||||
</template>
|
||||
<template v-else-if="name === 'lightbulb'">
|
||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
|
||||
<path d="M9 18h6" /><path d="M10 22h4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dot'">
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -95,6 +95,26 @@ export function useParallelRun() {
|
||||
dialogues.value = [...dialogues.value];
|
||||
break;
|
||||
}
|
||||
case "agent_clarification": {
|
||||
flushPending();
|
||||
const d = findDialogue(ev.agent_id);
|
||||
if (d) {
|
||||
if (ev.content) d.content = ev.content;
|
||||
d.clarification = {
|
||||
question: ev.question,
|
||||
clarificationType: ev.clarification_type,
|
||||
context: ev.clarification_context,
|
||||
options: ev.options,
|
||||
allowCustom: ev.allow_custom,
|
||||
allowMultiple: ev.allow_multiple,
|
||||
};
|
||||
d.streaming = false;
|
||||
d.confidence = "待确认";
|
||||
}
|
||||
setSeatStatus(ev.agent_id, "done");
|
||||
dialogues.value = [...dialogues.value];
|
||||
break;
|
||||
}
|
||||
case "agent_error": {
|
||||
flushPending();
|
||||
const d = findDialogue(ev.agent_id);
|
||||
@@ -178,6 +198,48 @@ export function useParallelRun() {
|
||||
running.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重跑**单个**智能体(用于回答澄清后继续):清掉它的旧气泡/步骤/澄清,带上新消息重新
|
||||
* 流式,只更新该席位列,其它席位不动。
|
||||
*/
|
||||
async function rerunAgent(
|
||||
cfg: ApiConfig,
|
||||
args: { taskName: string; brief: string; agentId: string; model?: string },
|
||||
): Promise<void> {
|
||||
if (running.value) return;
|
||||
const d = dialogues.value.find((x) => x.agentId === args.agentId);
|
||||
if (!d) return;
|
||||
d.content = "";
|
||||
d.steps = [];
|
||||
d.clarification = undefined;
|
||||
d.streaming = true;
|
||||
d.confidence = "并行执行中";
|
||||
d.time = nowTime();
|
||||
setSeatStatus(args.agentId, "running");
|
||||
dialogues.value = [...dialogues.value];
|
||||
|
||||
running.value = true;
|
||||
const ac = new AbortController();
|
||||
abortRef.value = ac;
|
||||
try {
|
||||
await streamParallelAgents(
|
||||
cfg,
|
||||
{ task_name: args.taskName, brief: args.brief, agent_ids: [args.agentId], model: args.model },
|
||||
{ onEvent: handleEvent, onError: (err) => console.error("[parallel-run]", err) },
|
||||
ac.signal,
|
||||
);
|
||||
} finally {
|
||||
const dd = dialogues.value.find((x) => x.agentId === args.agentId);
|
||||
if (dd && dd.streaming) {
|
||||
dd.streaming = false;
|
||||
if (dd.confidence === "并行执行中") dd.confidence = "已完成";
|
||||
}
|
||||
dialogues.value = [...dialogues.value];
|
||||
running.value = false;
|
||||
abortRef.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 回显:从保存的对话列表重建(并据此还原席位条为已完成)。 */
|
||||
function hydrate(saved: Dialogue[]) {
|
||||
cancel();
|
||||
@@ -208,5 +270,5 @@ export function useParallelRun() {
|
||||
seats.value = [];
|
||||
}
|
||||
|
||||
return { dialogues, seats, running, start, cancel, hydrate, reset };
|
||||
return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent };
|
||||
}
|
||||
@@ -12,5 +12,8 @@ export const DEFAULT_BASE_URL: string =
|
||||
export const DEFAULT_MODEL: string | undefined =
|
||||
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_MODEL || undefined;
|
||||
|
||||
/** 免密「用户名登录」端点(公共 + CSRF 豁免)。 */
|
||||
export const USERNAME_LOGIN_PATH = "/api/v1/auth/login/username";
|
||||
/**
|
||||
* 取 token 端点:本面板**专用**的「绕过常规登录」公共端点(传用户名直接签发 token)。
|
||||
* 内网部署对标准 /api/v1/auth/login/username 有额外限制,故走这个专用端点。
|
||||
*/
|
||||
export const TOKEN_PATH = "/api/parallel-agents/auth/token";
|
||||
@@ -9,7 +9,7 @@ import type { App } from "vue";
|
||||
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
|
||||
|
||||
export { ParallelAgentsPanel };
|
||||
export type { AgentInput, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
|
||||
export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
|
||||
|
||||
export default {
|
||||
install(app: App) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 步骤条渲染辅助(Vue 端,1:1 对齐圆桌 `roundtable-planning/lib/step-display.tsx`)。
|
||||
* 把 step.key(`tool_calling:<toolName>`)+ args 翻译成「图标 key + 中文文案」,
|
||||
* 以及参数子节点的结构化描述(chip / code / text / kv),供 MessageStepsCard 渲染。
|
||||
*/
|
||||
|
||||
export type IconKey =
|
||||
| "search"
|
||||
| "globe"
|
||||
| "folder"
|
||||
| "book"
|
||||
| "pen"
|
||||
| "terminal"
|
||||
| "todo"
|
||||
| "question"
|
||||
| "wrench"
|
||||
| "lightbulb"
|
||||
| "dot";
|
||||
|
||||
export interface StepDisplay {
|
||||
icon: IconKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
}
|
||||
|
||||
/** key=`tool_calling:<toolName>` + args → 图标 + 中文文案(对齐主聊天 toolCalls 文案)。 */
|
||||
export function getStepDisplay(key: string, fallbackLabel: string, args?: Record<string, unknown> | null): StepDisplay {
|
||||
const [phase, toolName] = key.split(":");
|
||||
if (phase === "tool_calling" && toolName) {
|
||||
switch (toolName) {
|
||||
case "web_search": {
|
||||
const q = str(args?.query);
|
||||
return { icon: "search", label: q ? `在网络上搜索 “${q}”` : "搜索相关信息" };
|
||||
}
|
||||
case "image_search": {
|
||||
const q = str(args?.query);
|
||||
return { icon: "search", label: q ? `搜索相关图片 “${q}”` : "搜索相关图片" };
|
||||
}
|
||||
case "web_fetch":
|
||||
return { icon: "globe", label: "查看网页" };
|
||||
case "ls":
|
||||
return { icon: "folder", label: str(args?.description) || "列出文件夹" };
|
||||
case "read_file":
|
||||
return { icon: "book", label: str(args?.description) || "读取文件" };
|
||||
case "write_file":
|
||||
case "str_replace":
|
||||
return { icon: "pen", label: str(args?.description) || "写入文件" };
|
||||
case "bash":
|
||||
return { icon: "terminal", label: str(args?.description) || "执行命令" };
|
||||
case "write_todos":
|
||||
return { icon: "todo", label: "更新 To-do 列表" };
|
||||
case "ask_clarification":
|
||||
return { icon: "question", label: "需要你的协助" };
|
||||
case "present_files":
|
||||
return { icon: "folder", label: "展示文件" };
|
||||
case "agent_orchestration":
|
||||
return { icon: "wrench", label: "派活给子智能体" };
|
||||
default:
|
||||
return { icon: "wrench", label: `使用 “${toolName}” 工具` };
|
||||
}
|
||||
}
|
||||
if (phase === "streaming") return { icon: "lightbulb", label: fallbackLabel };
|
||||
return { icon: "dot", label: fallbackLabel };
|
||||
}
|
||||
|
||||
/** 参数子节点的结构化描述(供模板按 kind 渲染)。null = 不渲染。 */
|
||||
export type StepArgs =
|
||||
| { kind: "chip"; text: string }
|
||||
| { kind: "code"; text: string }
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "kv"; pairs: Array<{ k: string; v: string }> }
|
||||
| null;
|
||||
|
||||
export function getStepArgs(key: string, args?: Record<string, unknown> | null): StepArgs {
|
||||
if (!args) return null;
|
||||
const [, toolName] = key.split(":");
|
||||
if (!toolName) return null;
|
||||
switch (toolName) {
|
||||
case "web_search":
|
||||
case "image_search": {
|
||||
const q = str(args.query);
|
||||
return q ? { kind: "chip", text: q } : null;
|
||||
}
|
||||
case "web_fetch": {
|
||||
const u = str(args.url);
|
||||
return u ? { kind: "chip", text: u } : null;
|
||||
}
|
||||
case "read_file":
|
||||
case "ls":
|
||||
case "write_file":
|
||||
case "str_replace": {
|
||||
const p = str(args.path);
|
||||
return p ? { kind: "chip", text: p } : null;
|
||||
}
|
||||
case "bash": {
|
||||
const c = str(args.command);
|
||||
return c ? { kind: "code", text: c } : null;
|
||||
}
|
||||
case "write_todos":
|
||||
return null;
|
||||
case "ask_clarification": {
|
||||
const q = str(args.question);
|
||||
return q ? { kind: "text", text: q } : null;
|
||||
}
|
||||
case "agent_orchestration": {
|
||||
const task = str(args.task);
|
||||
return task ? { kind: "text", text: task } : null;
|
||||
}
|
||||
default: {
|
||||
const pairs = Object.entries(args)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => ({ k, v: typeof v === "string" ? v : JSON.stringify(v) }));
|
||||
return pairs.length ? { kind: "kv", pairs } : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。
|
||||
*/
|
||||
|
||||
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。 */
|
||||
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。`key` 形如 `tool_calling:<toolName>`。 */
|
||||
export interface DialogueStep {
|
||||
id: string;
|
||||
key: string;
|
||||
@@ -14,6 +14,19 @@ export interface DialogueStep {
|
||||
args?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** 澄清/协作卡片的一个选项(字符串或带描述的对象)。 */
|
||||
export type ClarOption = string | { id?: string; label?: string; description?: string };
|
||||
|
||||
/** 智能体向用户要澄清时的结构化载荷(对齐圆桌第一步协作卡片)。 */
|
||||
export interface ClarificationPayload {
|
||||
question: string;
|
||||
clarificationType: string;
|
||||
context: string;
|
||||
options: ClarOption[];
|
||||
allowCustom: boolean;
|
||||
allowMultiple: boolean;
|
||||
}
|
||||
|
||||
/** 一条消息气泡(对齐 Step2Dialogue + agentId)。 */
|
||||
export interface Dialogue {
|
||||
id: number;
|
||||
@@ -27,6 +40,8 @@ export interface Dialogue {
|
||||
confidence: string;
|
||||
streaming?: boolean;
|
||||
steps?: DialogueStep[];
|
||||
/** 该智能体在要澄清时的协作卡片载荷(有则渲染卡片)。 */
|
||||
clarification?: ClarificationPayload;
|
||||
}
|
||||
|
||||
/** 并行席位的视图状态(席位状态条用)。 */
|
||||
@@ -51,10 +66,30 @@ export type StreamEvent =
|
||||
| { type: "agent_start"; agent_id: string }
|
||||
| { type: "delta"; agent_id: string; text: string }
|
||||
| { type: "step"; agent_id: string; key: string; label: string; args?: Record<string, unknown> | null }
|
||||
| {
|
||||
type: "agent_clarification";
|
||||
agent_id: string;
|
||||
content: string;
|
||||
question: string;
|
||||
clarification_type: string;
|
||||
clarification_context: string;
|
||||
options: ClarOption[];
|
||||
allow_custom: boolean;
|
||||
allow_multiple: boolean;
|
||||
}
|
||||
| { type: "agent_done"; agent_id: string; content: string }
|
||||
| { type: "agent_error"; agent_id: string; message: string }
|
||||
| { type: "all_done" };
|
||||
|
||||
/** 单个智能体的返回结果(供宿主通过 getResults() / completed 事件读取)。 */
|
||||
export interface AgentResult {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
/** 该智能体模型返回的正文(Markdown)。 */
|
||||
content: string;
|
||||
status: "pending" | "running" | "done" | "error";
|
||||
}
|
||||
|
||||
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
|
||||
export interface SavedTask {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user