代码提交

This commit is contained in:
董宏凯 committed 2026-06-08 22:52:18 +08:00
1 parent b101ee7fc9
commit 1938c80061
42 files changed
+5552 -278

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput } 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",
agentsRaw: localStorage.getItem("pa.agents") || "",
});
const open = ref(false);
const agents = ref<AgentInput[]>([]);
function parseAgents(): AgentInput[] {
// 每行一个:`agent_id, 显示名` 或仅 `agent_id`。
return cfg.agentsRaw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [id, name] = l.split(",").map((x) => x.trim());
return { agent_id: id, name: name || id };
});
}
function launch() {
localStorage.setItem("pa.baseUrl", cfg.baseUrl);
localStorage.setItem("pa.username", cfg.username);
localStorage.setItem("pa.agents", cfg.agentsRaw);
agents.value = parseAgents();
open.value = true;
}
</script>
<template>
<div style="min-height: 100vh; background: #0a1020; color: #cbd5e1; padding: 24px; font-family: system-ui">
<h1 style="font-size: 18px; font-weight: 700; margin-bottom: 12px">并行多智能体面板 · Dev Demo</h1>
<div style="display: flex; flex-direction: column; gap: 8px; max-width: 640px">
<label>后端 baseUrl
<input v-model="cfg.baseUrl" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>用户名(免密自动登录,由你自己传)
<input v-model="cfg.username" 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, 情报收集&#10;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">
打开面板(弹窗内)
</button>
</div>
<!-- 模拟宿主弹窗:面板自适应容器 -->
<div
v-if="open"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
@click.self="open = false"
>
<div style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5)">
<ParallelAgentsPanel
:base-url="cfg.baseUrl"
:username="cfg.username || undefined"
:agents="agents"
/>
</div>
</div>
</div>
</template>
+236
View File
@@ -0,0 +1,236 @@
/**
* 后端访问层:鉴权(宿主传入 token)+ 并行扇出 SSE + 任务 CRUD。
*
* 全部打到 DeerFlow 网关(宿主传入 `baseUrl`,如 http://localhost:8001):
* - `POST {baseUrl}/api/parallel-agents/run/stream` —— 并行扇出,合并 SSE;
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
*/
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
import { USERNAME_LOGIN_PATH } from "./config";
export interface ApiConfig {
baseUrl: string;
/** 已有登录态 token(Bearer)。可传字符串或一个(异步)取 token 的函数。 */
token?: string;
getToken?: () => string | Promise<string>;
/**
* 免密自动登录用户名:不传 token 时,组件用它走「用户名登录」(公共端点)换取 token
* 并缓存。用户名由宿主自己传。
*/
username?: string;
/** 仅当后端 `auth_login.username_login.require_password=true` 时需要的口令。 */
loginPassword?: string;
}
function base(cfg: ApiConfig): string {
return cfg.baseUrl.replace(/\/$/, "");
}
// 免密登录拿到的 token 按 baseUrl::username 缓存,避免每个请求都登一次。
const _tokenCache = new Map<string, string>();
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
/** 用 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, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: cfg.username }),
credentials: "include",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`username login failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { access_token: string; token_type?: string };
const token = data.access_token;
_tokenCache.set(cacheKey(cfg), token);
return token;
}
/** token 解析优先级:显式 token > getToken > 缓存 > 用 username 免密登录。 */
async function resolveToken(cfg: ApiConfig): Promise<string | undefined> {
if (cfg.token) return cfg.token;
if (cfg.getToken) return await cfg.getToken();
const cached = _tokenCache.get(cacheKey(cfg));
if (cached) return cached;
if (cfg.username) return await loginByUsername(cfg);
return undefined;
}
/** 清掉缓存 token(401 时强制下次重登)。 */
function invalidateToken(cfg: ApiConfig): void {
_tokenCache.delete(cacheKey(cfg));
}
async function authHeaders(cfg: ApiConfig, json = true): Promise<Record<string, string>> {
const h: Record<string, string> = {};
if (json) h["Content-Type"] = "application/json";
const token = await resolveToken(cfg);
if (token) h["Authorization"] = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
return h;
}
/** 带鉴权的 fetch;若 401 且用 username 免密登录,则清缓存重登并重试一次。 */
async function authedFetch(
cfg: ApiConfig,
url: string,
init: RequestInit & { json?: boolean } = {},
): Promise<Response> {
const { json = true, headers, ...rest } = init;
const run = async () =>
fetch(url, {
...rest,
headers: { ...(await authHeaders(cfg, json)), ...(headers as Record<string, string> | undefined) },
credentials: "include",
});
let res = await run();
if (res.status === 401 && !cfg.token && !cfg.getToken && cfg.username) {
invalidateToken(cfg);
res = await run();
}
return res;
}
// ── 并行扇出 SSE ──────────────────────────────────────────────────────────────
export interface StreamHandlers {
onEvent: (ev: StreamEvent) => void;
onError?: (err: unknown) => void;
}
/**
* 打开 `/api/parallel-agents/run/stream` 一条合并 SSE,逐条把统一事件回调出去。
* 返回的 Promise 在流结束(all_done / 连接关闭)后 resolve。用 `signal` 取消。
*/
export async function streamParallelAgents(
cfg: ApiConfig,
body: { task_name: string; brief: string; agent_ids: string[]; model?: string },
handlers: StreamHandlers,
signal?: AbortSignal,
): Promise<void> {
const res = await authedFetch(cfg, `${base(cfg)}/api/parallel-agents/run/stream`, {
method: "POST",
body: JSON.stringify(body),
signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try {
handlers.onEvent(JSON.parse(payload) as StreamEvent);
} catch {
/* 忽略坏帧 */
}
}
}
} catch (err) {
if ((err as { name?: string })?.name !== "AbortError") handlers.onError?.(err);
} finally {
reader.releaseLock();
}
}
// ── 任务 CRUD(保存 / 回显)─────────────────────────────────────────────────
interface ServerTask {
id: string;
title: string;
brief: string | null;
agents: AgentInput[];
messages?: Dialogue[];
created_at: string | null;
updated_at: string | null;
}
const TASKS = (cfg: ApiConfig) => `${base(cfg)}/api/parallel-agent-tasks`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
export async function listTasks(cfg: ApiConfig): Promise<SavedTask[]> {
const res = await authedFetch(cfg, TASKS(cfg), { json: false });
const data = await readJson<{ tasks: ServerTask[] }>(res, "list tasks");
return data.tasks ?? [];
}
export async function getTask(cfg: ApiConfig, id: string): Promise<SavedTask | null> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, { json: false });
if (res.status === 404) return null;
return readJson<SavedTask>(res, "get task");
}
export async function createTask(
cfg: ApiConfig,
payload: { id?: string; title: string; brief?: string; agents: AgentInput[]; messages: Dialogue[] },
): Promise<SavedTask> {
const res = await authedFetch(cfg, TASKS(cfg), { method: "POST", body: JSON.stringify(payload) });
return readJson<SavedTask>(res, "create task");
}
export async function updateTask(
cfg: ApiConfig,
id: string,
patch: { title?: string; brief?: string; agents?: AgentInput[]; messages?: Dialogue[] },
): Promise<SavedTask | null> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(patch),
});
if (res.status === 404) return null;
return readJson<SavedTask>(res, "update task");
}
// ── 模型列表 ──────────────────────────────────────────────────────────────
export interface ModelItem {
name: string;
display_name?: string;
}
/** 列出后端可用模型(GET /api/models)。失败返回空数组(不阻断主流程)。 */
export async function listModels(cfg: ApiConfig): Promise<ModelItem[]> {
try {
const res = await authedFetch(cfg, `${base(cfg)}/api/models`, { json: false });
if (!res.ok) return [];
const data = (await res.json()) as { models?: ModelItem[] };
return data.models ?? [];
} catch {
return [];
}
}
export async function deleteTask(cfg: ApiConfig, id: string): Promise<void> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
method: "DELETE",
json: false,
});
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete task failed (${res.status}): ${text}`);
}
}
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from "vue";
import { avatarColor, isHuman } from "@/lib/avatar-constants";
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
const color = computed(() => avatarColor(props.type));
const human = computed(() => isHuman(props.type));
</script>
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-xl"
:style="{ width: `${size}px`, height: `${size}px`, backgroundColor: color.bg }"
>
<!-- 真人 -->
<svg
v-if="human"
:width="size * 0.58"
:height="size * 0.58"
viewBox="0 0 24 24"
fill="none"
:stroke="color.fg"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
</svg>
<!-- 机器人 -->
<svg
v-else
:width="size * 0.6"
:height="size * 0.6"
viewBox="0 0 24 24"
fill="none"
:stroke="color.fg"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="4" y="8" width="16" height="11" rx="3" />
<path d="M12 8V4" />
<circle cx="12" cy="3" r="1" :fill="color.fg" />
<circle cx="9" cy="13" r="1.2" :fill="color.fg" stroke="none" />
<circle cx="15" cy="13" r="1.2" :fill="color.fg" stroke="none" />
<path d="M9 16.5h6" />
</svg>
</span>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { computed } from "vue";
import MarkdownIt from "markdown-it";
const props = defineProps<{ source: string }>();
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
const html = computed(() => md.render(props.source ?? ""));
</script>
<template>
<div class="pa-md" v-html="html" />
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
import { computed } from "vue";
import type { Dialogue } from "@/types";
import Avatar from "./Avatar.vue";
import MessageStepsCard from "./MessageStepsCard.vue";
import MarkdownContent from "./MarkdownContent.vue";
const props = defineProps<{ msg: Dialogue }>();
const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
</script>
<template>
<div class="flex items-start gap-3">
<Avatar :type="msg.avatarType" :size="36" />
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-extrabold pa-text">{{ msg.sender }}</span>
<span v-if="msg.confidence" class="pa-badge">{{ msg.confidence }}</span>
</div>
<span class="shrink-0 font-mono text-[11px] pa-text-muted">{{ msg.time }}</span>
</div>
<MessageStepsCard
v-if="msg.steps && msg.steps.length"
:steps="msg.steps"
:streaming="msg.streaming"
/>
<div class="pa-text break-words">
<div
v-if="msg.streaming && !hasContent"
class="flex items-center gap-2 py-1 text-sm pa-text-muted"
>
<span class="pa-spin inline-block">◜</span>
<span>{{ msg.confidence || "正在生成…" }}</span>
<span class="inline-flex items-center gap-0.5">
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary)" />
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.15s" />
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.3s" />
</span>
</div>
<MarkdownContent v-if="hasContent" :source="msg.content" />
<span v-if="msg.streaming && hasContent" class="pa-cursor" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
/** 步骤条 / 思维链卡:折叠展示该气泡运行期间的工具调用步骤(对齐 Step2 MessageStepsCard)。 */
import { ref } from "vue";
import type { DialogueStep } from "@/types";
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 "";
}
}
</script>
<template>
<div class="pa-surface pa-border mb-2 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>执行步骤({{ 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"
>
<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>
</template>
@@ -0,0 +1,329 @@
<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, Dialogue, SavedTask } 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;
}>();
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 },
);
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
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] : [];
});
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,
});
} 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 }));
let saved: SavedTask | null;
if (currentTaskId.value) {
saved = await updateTask(cfg.value, currentTaskId.value, {
title,
brief: form.brief,
agents: agentsSnapshot,
messages: dialogues.value,
});
} else {
saved = await createTask(cfg.value, {
title,
brief: form.brief,
agents: agentsSnapshot,
messages: dialogues.value,
});
}
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)!"
/>
<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>
@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
* 点击可切换主区显示哪两列。 */
import { computed } from "vue";
import type { SeatView } from "@/types";
import Avatar from "./Avatar.vue";
const props = defineProps<{ seats: SeatView[]; selected: string[] }>();
const emit = defineEmits<{ (e: "toggle", agentId: string): void }>();
const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length);
</script>
<template>
<div class="flex items-center gap-2 overflow-x-auto py-1">
<span class="shrink-0 text-[11px] font-bold pa-text-muted">
并行 {{ seats.length }} 席
<span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span>
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
</span>
<div class="flex items-center gap-2">
<button
v-for="seat in seats"
:key="seat.agentId"
type="button"
class="relative shrink-0 rounded-full transition"
:style="
selected.includes(seat.agentId)
? 'box-shadow: 0 0 0 2px var(--pa-primary)'
: ''
"
:title="seat.name"
@click="emit('toggle', seat.agentId)"
>
<span
v-if="seat.status === 'running'"
class="pa-ping absolute inset-0 rounded-full"
style="background: rgba(22, 119, 255, 0.3)"
/>
<span class="relative block" :style="!selected.includes(seat.agentId) ? 'opacity:0.8' : ''">
<Avatar :type="seat.avatarType" :size="28" />
</span>
<span
class="absolute -bottom-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full text-[8px] font-black"
style="border: 1px solid var(--pa-bg)"
:class="{
'pa-dot-running': seat.status === 'running',
'pa-dot-done': seat.status === 'done',
'pa-dot-error': seat.status === 'error',
}"
:style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''"
>
<span v-if="seat.status === 'running'" class="pa-spin">◜</span>
<span v-else-if="seat.status === 'done'">✓</span>
<span v-else-if="seat.status === 'error'">✕</span>
</span>
</button>
</div>
</div>
</template>
@@ -0,0 +1,212 @@
/**
* 并行运行编排(前端态)。消费后端合并 SSE,按 agent_id 把增量/步骤/收口路由到对应席位
* 气泡,维护并行席位状态条;带轻量节流(rAF 合并增量),支持取消、回显 hydrate、重置。
*/
import { ref, shallowRef } from "vue";
import type { AgentInput, Dialogue, SeatView, StreamEvent } from "@/types";
import { getAvatarTypeFor } from "@/lib/avatar-constants";
import { streamParallelAgents, type ApiConfig } from "@/api";
function nowTime(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
let _seq = 1;
function nextId(): number {
return _seq++;
}
export function useParallelRun() {
const dialogues = ref<Dialogue[]>([]);
const seats = ref<SeatView[]>([]);
const running = ref(false);
const abortRef = shallowRef<AbortController | null>(null);
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
const pending = new Map<string, string>(); // agentId -> 待追加文本
let rafScheduled = false;
const bubbleByAgent = new Map<string, number>(); // agentId -> dialogue.id
function flushPending() {
rafScheduled = false;
if (pending.size === 0) return;
for (const [agentId, text] of pending) {
const bid = bubbleByAgent.get(agentId);
const d = dialogues.value.find((x) => x.id === bid);
if (d) d.content += text;
}
pending.clear();
// 触发数组响应(对象内字段已变更,这里强制一次浅刷新以更新双列视图)。
dialogues.value = [...dialogues.value];
}
function scheduleFlush() {
if (rafScheduled) return;
rafScheduled = true;
requestAnimationFrame(flushPending);
}
function setSeatStatus(agentId: string, status: SeatView["status"]) {
const s = seats.value.find((x) => x.agentId === agentId);
if (s) s.status = status;
seats.value = [...seats.value];
}
function findDialogue(agentId: string): Dialogue | undefined {
const bid = bubbleByAgent.get(agentId);
return dialogues.value.find((x) => x.id === bid);
}
function handleEvent(ev: StreamEvent) {
switch (ev.type) {
case "agent_start":
setSeatStatus(ev.agent_id, "running");
break;
case "delta":
pending.set(ev.agent_id, (pending.get(ev.agent_id) ?? "") + ev.text);
scheduleFlush();
break;
case "step": {
const d = findDialogue(ev.agent_id);
if (d) {
d.steps = d.steps ?? [];
d.steps.push({
id: `${d.id}-${d.steps.length}`,
key: ev.key,
label: ev.label,
ts: nowTime(),
args: ev.args ?? null,
});
dialogues.value = [...dialogues.value];
}
break;
}
case "agent_done": {
flushPending();
const d = findDialogue(ev.agent_id);
if (d) {
if (ev.content) d.content = ev.content; // 终态正文以后端为准
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);
if (d) {
d.content = d.content || `⚠️ 该智能体执行出错:${ev.message}`;
d.streaming = false;
d.confidence = "出错";
}
setSeatStatus(ev.agent_id, "error");
dialogues.value = [...dialogues.value];
break;
}
case "all_done":
flushPending();
break;
}
}
/** 启动一轮并行运行。会清空旧对话、为每个 agent 建一个流式气泡。 */
async function start(
cfg: ApiConfig,
args: { taskName: string; brief: string; agents: AgentInput[]; model?: string },
): Promise<void> {
if (running.value) return;
reset();
running.value = true;
bubbleByAgent.clear();
const newSeats: SeatView[] = [];
const newDialogues: Dialogue[] = [];
args.agents.forEach((a, i) => {
const avatarType = a.avatarType || getAvatarTypeFor(a.agent_id, i);
const bubbleId = nextId();
bubbleByAgent.set(a.agent_id, bubbleId);
newDialogues.push({
id: bubbleId,
agentId: a.agent_id,
sender: a.name || a.agent_id,
avatarType,
content: "",
time: nowTime(),
confidence: "并行执行中",
streaming: true,
steps: [],
});
newSeats.push({ agentId: a.agent_id, name: a.name || a.agent_id, avatarType, status: "pending", bubbleId });
});
dialogues.value = newDialogues;
seats.value = newSeats;
const ac = new AbortController();
abortRef.value = ac;
try {
await streamParallelAgents(
cfg,
{
task_name: args.taskName,
brief: args.brief,
agent_ids: args.agents.map((a) => a.agent_id),
model: args.model,
},
{ onEvent: handleEvent, onError: (err) => console.error("[parallel-run]", err) },
ac.signal,
);
} finally {
// 收尾:任何仍在 streaming 的气泡都置完成态。
for (const d of dialogues.value) {
if (d.streaming) {
d.streaming = false;
if (d.confidence === "并行执行中") d.confidence = "已完成";
}
}
dialogues.value = [...dialogues.value];
running.value = false;
abortRef.value = null;
}
}
function cancel() {
abortRef.value?.abort();
running.value = false;
}
/** 回显:从保存的对话列表重建(并据此还原席位条为已完成)。 */
function hydrate(saved: Dialogue[]) {
cancel();
bubbleByAgent.clear();
const restored = saved.map((d) => ({ ...d, streaming: false }));
dialogues.value = restored;
const seatMap = new Map<string, SeatView>();
restored.forEach((d) => {
bubbleByAgent.set(d.agentId, d.id);
if (!seatMap.has(d.agentId)) {
seatMap.set(d.agentId, {
agentId: d.agentId,
name: d.sender,
avatarType: d.avatarType,
status: d.confidence === "出错" ? "error" : "done",
bubbleId: d.id,
});
}
});
seats.value = [...seatMap.values()];
}
function reset() {
cancel();
pending.clear();
bubbleByAgent.clear();
dialogues.value = [];
seats.value = [];
}
return { dialogues, seats, running, start, cancel, hydrate, reset };
}
+16
View File
@@ -0,0 +1,16 @@
/**
* 前端配置:后端接口地址等。嵌入方一般直接改这里的默认值即可;也可在 dev 用
* Vite 环境变量覆盖(VITE_PA_BASE_URL)。组件 props 传入时优先级最高。
*/
/** DeerFlow 网关默认地址(接口都打到这里)。 */
export const DEFAULT_BASE_URL: string =
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_PA_BASE_URL ||
"http://localhost:8001";
/** 可选默认模型名(不填由后端回退到 config.yaml models[0])。 */
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";
+18
View File
@@ -0,0 +1,18 @@
/**
* 组件库入口。其它 Vue 项目:
* import { ParallelAgentsPanel } from "parallel-agents-vue";
* import "parallel-agents-vue/style.css";
* 或全局注册:app.use(ParallelAgentsVue)。
*/
import "./styles/parallel-agents.css";
import type { App } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
export { ParallelAgentsPanel };
export type { AgentInput, Dialogue, SavedTask, SeatView, StreamEvent } from "./types";
export default {
install(app: App) {
app.component("ParallelAgentsPanel", ParallelAgentsPanel);
},
};
@@ -0,0 +1,27 @@
/** 头像配色(对齐圆桌 Step2 的 avatarType:blue/purple/emerald/amber/pink/human)。 */
export const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const;
/** 无显式 avatarType 时按下标轮转一个配色(与 Step2 getAvatarTypeFor 行为一致)。 */
export function getAvatarTypeFor(_agentId: string, index: number): string {
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length];
}
/** 每种 avatarType 在深色面板里的底色 + 主体色(机器人头 / 真人)。 */
export const AVATAR_COLORS: Record<string, { bg: string; fg: string }> = {
blue: { bg: "rgba(22,119,255,0.18)", fg: "#4096ff" },
purple: { bg: "rgba(124,58,237,0.20)", fg: "#a78bfa" },
emerald: { bg: "rgba(16,185,129,0.20)", fg: "#34d399" },
amber: { bg: "rgba(245,158,11,0.20)", fg: "#fbbf24" },
pink: { bg: "rgba(219,39,119,0.20)", fg: "#f472b6" },
human: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
man: { bg: "rgba(148,163,184,0.20)", fg: "#cbd5e1" },
};
export function avatarColor(type: string): { bg: string; fg: string } {
return AVATAR_COLORS[type] ?? AVATAR_COLORS.blue;
}
export function isHuman(type: string): boolean {
return type === "human" || type === "man";
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles/parallel-agents.css";
createApp(App).mount("#app");
@@ -0,0 +1,282 @@
/* 并行多智能体面板样式。
*
* 嵌入安全:只引 Tailwind 的 theme + utilities(**不引 preflight**,避免重置宿主全局样式)。
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
* 深色一致),全部包在根类 `.parallel-agents` 下,绝不污染宿主。布局用 Tailwind 工具类,
* 主题色用下面的 `.pa-*` 语义类。 */
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
.parallel-agents {
--pa-bg: #0c1836; /* 页面背景 zs-004-09 */
--pa-card: #122347; /* 卡片 / 气泡容器 zs-004-08 */
--pa-muted: #16294d; /* 次级表面(步骤卡 / 并行列底) */
--pa-accent: #1a2f5a; /* hover / 软高亮 */
--pa-fg: #c5cfe0; /* 正文 zs-004-02 */
--pa-fg-muted: #6e88b0; /* 次级文字 zs-004-04 */
--pa-border: #2d4473; /* 容器边框 zs-004-06 */
--pa-input: #4a6491; /* 控件描边 */
--pa-primary: #1677ff; /* 主操作 zs-002-06 */
--pa-primary-fg: #ffffff;
--pa-chip-bg: #16335f; /* badge 底 */
--pa-chip-fg: #4096ff; /* badge 文字 */
--pa-amber: #f59e0b;
--pa-emerald: #10b981;
--pa-rose: #ef4444;
color: var(--pa-fg);
background-color: var(--pa-bg);
font-size: 14px;
line-height: 1.6;
box-sizing: border-box;
}
.parallel-agents *,
.parallel-agents *::before,
.parallel-agents *::after {
box-sizing: border-box;
}
/* 滚动条 */
.parallel-agents ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.parallel-agents ::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--pa-primary) 40%, var(--pa-border));
border-radius: 999px;
}
.parallel-agents ::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
}
/* ── 语义颜色类(组件用这些上色,布局仍用 Tailwind 工具类)──────────────────── */
.parallel-agents .pa-bg {
background-color: var(--pa-bg);
}
.parallel-agents .pa-card {
background-color: var(--pa-card);
}
.parallel-agents .pa-surface {
background-color: var(--pa-muted);
}
.parallel-agents .pa-surface-soft {
background-color: color-mix(in oklch, var(--pa-muted) 55%, transparent);
}
.parallel-agents .pa-border {
border-color: var(--pa-border);
}
.parallel-agents .pa-text {
color: var(--pa-fg);
}
.parallel-agents .pa-text-muted {
color: var(--pa-fg-muted);
}
.parallel-agents .pa-text-primary {
color: var(--pa-primary);
}
/* 输入框 / 文本域 */
.parallel-agents .pa-input {
background-color: var(--pa-card);
border: 1px solid var(--pa-border);
color: var(--pa-fg);
border-radius: 8px;
}
.parallel-agents .pa-input::placeholder {
color: color-mix(in oklch, var(--pa-fg-muted) 80%, transparent);
}
.parallel-agents .pa-input:focus {
outline: none;
border-color: color-mix(in oklch, var(--pa-primary) 55%, var(--pa-border));
}
/* 主按钮 / 次按钮 */
.parallel-agents .pa-btn-primary {
background-color: var(--pa-primary);
color: var(--pa-primary-fg);
border-radius: 8px;
transition: filter 0.15s ease;
}
.parallel-agents .pa-btn-primary:hover {
filter: brightness(1.08);
}
.parallel-agents .pa-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.parallel-agents .pa-btn-ghost {
border: 1px solid var(--pa-border);
color: var(--pa-fg);
background-color: transparent;
border-radius: 8px;
transition: background-color 0.15s ease;
}
.parallel-agents .pa-btn-ghost:hover {
background-color: var(--pa-accent);
}
.parallel-agents .pa-btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 状态徽标("实时输出" / "已完成" …)—— 对齐 Step2 confidence chip */
.parallel-agents .pa-badge {
background-color: var(--pa-chip-bg);
color: var(--pa-chip-fg);
border: 1px solid var(--pa-border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 800;
text-transform: uppercase;
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
letter-spacing: 0.02em;
}
/* 席位状态角标色 */
.parallel-agents .pa-dot-running {
background-color: var(--pa-primary);
color: #fff;
}
.parallel-agents .pa-dot-done {
background-color: var(--pa-emerald);
color: #fff;
}
.parallel-agents .pa-dot-error {
background-color: var(--pa-rose);
color: #fff;
}
/* 流式光标 + loading 点 */
.parallel-agents .pa-cursor {
display: inline-block;
width: 4px;
height: 13px;
margin-left: 2px;
vertical-align: middle;
background: var(--pa-primary);
animation: pa-pulse 1s ease-in-out infinite;
}
@keyframes pa-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.parallel-agents .pa-bounce {
animation: pa-bounce 0.9s infinite;
}
@keyframes pa-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes pa-spin {
to {
transform: rotate(360deg);
}
}
.parallel-agents .pa-spin {
animation: pa-spin 0.8s linear infinite;
}
@keyframes pa-ping {
75%,
100% {
transform: scale(1.8);
opacity: 0;
}
}
.parallel-agents .pa-ping {
animation: pa-ping 1.2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* ── Markdown 正文(对齐 Step2 sharedMarkdownComponents 的深色观感)──────────── */
.parallel-agents .pa-md {
color: var(--pa-fg);
font-size: 14px;
line-height: 1.7;
word-break: break-word;
}
.parallel-agents .pa-md p {
margin: 0.35em 0;
}
.parallel-agents .pa-md h1,
.parallel-agents .pa-md h2,
.parallel-agents .pa-md h3,
.parallel-agents .pa-md h4 {
font-weight: 700;
margin: 0.7em 0 0.35em;
color: var(--pa-fg);
}
.parallel-agents .pa-md h1 {
font-size: 1.3em;
}
.parallel-agents .pa-md h2 {
font-size: 1.18em;
}
.parallel-agents .pa-md h3 {
font-size: 1.06em;
}
.parallel-agents .pa-md ul,
.parallel-agents .pa-md ol {
margin: 0.35em 0;
padding-left: 1.4em;
}
.parallel-agents .pa-md li {
margin: 0.2em 0;
}
.parallel-agents .pa-md a {
color: var(--pa-chip-fg);
text-decoration: underline;
}
.parallel-agents .pa-md code {
background: var(--pa-muted);
border: 1px solid var(--pa-border);
border-radius: 4px;
padding: 0.1em 0.35em;
font-size: 0.9em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.parallel-agents .pa-md pre {
background: var(--pa-muted);
border: 1px solid var(--pa-border);
border-radius: 8px;
padding: 0.75em;
overflow-x: auto;
margin: 0.5em 0;
}
.parallel-agents .pa-md pre code {
background: transparent;
border: 0;
padding: 0;
}
.parallel-agents .pa-md blockquote {
border-left: 3px solid var(--pa-border);
padding-left: 0.75em;
color: var(--pa-fg-muted);
margin: 0.5em 0;
}
.parallel-agents .pa-md table {
border-collapse: collapse;
margin: 0.5em 0;
width: 100%;
}
.parallel-agents .pa-md th,
.parallel-agents .pa-md td {
border: 1px solid var(--pa-border);
padding: 0.35em 0.6em;
text-align: left;
}
.parallel-agents .pa-md th {
background: var(--pa-muted);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 并行多智能体面板的共享类型。
*
* `Dialogue` 镜像圆桌 Step2 的 `Step2Dialogue`,外加 `agentId`(并行场景下用来把消息
* 归到对应席位列)。`StreamEvent` 是后端 `/api/parallel-agents/run/stream` 的统一事件协议。
*/
/** 一条工具调用步骤(喂步骤条 / 思维链卡)。 */
export interface DialogueStep {
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown> | null;
}
/** 一条消息气泡(对齐 Step2Dialogue + agentId)。 */
export interface Dialogue {
id: number;
/** 归属席位的 agent_id(并行列定位用)。 */
agentId: string;
sender: string;
avatarType: string;
content: string;
time: string;
/** 状态标签("实时输出" / "已完成" / "出错" 等)。 */
confidence: string;
streaming?: boolean;
steps?: DialogueStep[];
}
/** 并行席位的视图状态(席位状态条用)。 */
export interface SeatView {
agentId: string;
name: string;
avatarType: string;
status: "pending" | "running" | "done" | "error";
/** 关联的 Dialogue.id。 */
bubbleId: number;
}
/** 宿主传入的一个待并行调用的智能体。 */
export interface AgentInput {
agent_id: string;
name: string;
avatarType?: string;
}
/** 后端合并 SSE 的统一事件。 */
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_done"; agent_id: string; content: string }
| { type: "agent_error"; agent_id: string; message: string }
| { type: "all_done" };
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
export interface SavedTask {
id: string;
title: string;
brief?: string | null;
agents: AgentInput[];
messages?: Dialogue[];
created_at?: string | null;
updated_at?: string | null;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}