代码提交

This commit is contained in:
董宏凯 committed 2026-06-09 22:52:12 +08:00
1 parent ace30af202
commit 64afa9a1ed
5 files changed
+409 -172

No files matched your search

+81 -12
View File
@@ -1,5 +1,10 @@
<script setup lang="ts">
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
/**
* Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。
*
* 同时作为「新契约」的参考实现:任务名 / 简介 / 模型由宿主持有并通过 props 传入,
* 「重新分析 / 保存 / 停止」等按钮由宿主渲染,通过模板 ref 调用面板 expose 出的方法。
*/
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput, AgentResult } from "./types";
@@ -16,13 +21,24 @@ const cfg = reactive({
const open = ref(false);
const agents = ref<AgentInput[]>([]);
// 宿主持有:任务名 / 简介 / 模型(面板不再渲染这些输入)。
const taskName = ref(localStorage.getItem("pa.taskName") || "");
const brief = ref(localStorage.getItem("pa.brief") || "");
const model = ref(localStorage.getItem("pa.model") || "");
// 模板 ref:拿到面板暴露的方法 / 状态。
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
// 面板保存 / 生成 id 时回填,方便复制下来下次填进去测「按 taskId 回显」。
function onTaskId(id: string) {
cfg.taskId = id;
localStorage.setItem("pa.taskId", id);
}
function onAgentCompleted(result: AgentResult) {
console.log("[demo] agent-completed → 某智能体分析完:", result.name, result.status, result.content);
}
function onCompleted(results: AgentResult[]) {
console.log("[demo] completed → 各智能体返回内容:", results);
console.log("[demo] completed → 全部分析完,各智能体返回内容:", results);
}
function parseAgents(): AgentInput[] {
@@ -68,21 +84,74 @@ function launch() {
</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"
:task-id="cfg.taskId || undefined"
:agents="agents"
@update:taskId="onTaskId"
@completed="onCompleted"
/>
<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); display: flex; flex-direction: column; background: #0d1526">
<!-- 宿主工具条:这部分原来在面板内,现在由宿主自己掌控 -->
<div style="display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border-bottom: 1px solid #1e2d4d">
<input
v-model="taskName"
placeholder="任务名称"
style="padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px"
/>
<textarea
v-model="brief"
rows="2"
placeholder="任务简介:把背景、目标、约束写清楚"
style="padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px; resize: vertical"
/>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
<!-- expose 出的 ref 在组件实例上会自动解包,直接读,无需 .value -->
<button
v-if="!panel?.running"
:disabled="!panel?.canStart"
@click="panel?.reanalyze()"
style="padding: 6px 14px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer"
>
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
</button>
<button
v-else
@click="panel?.cancel()"
style="padding: 6px 14px; background: #2d4473; color: #e5edf7; border: 0; border-radius: 6px; cursor: pointer"
>
停止
</button>
<button
:disabled="panel?.saving || !panel?.hasResults"
@click="panel?.save()"
style="padding: 6px 14px; background: #2d4473; color: #e5edf7; border: 0; border-radius: 6px; cursor: pointer"
>
{{ panel?.saving ? "保存中…" : cfg.taskId ? "更新保存" : "保存" }}
</button>
<input
v-model="model"
placeholder="模型(留空=默认)"
style="margin-left: auto; width: 200px; padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px"
/>
</div>
</div>
<!-- 面板:自适应容器,纯展示会话 -->
<div style="flex: 1; min-height: 0">
<ParallelAgentsPanel
ref="panel"
:base-url="cfg.baseUrl"
:username="cfg.username || undefined"
:task-id="cfg.taskId || undefined"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
:agents="agents"
@update:taskId="onTaskId"
@agent-completed="onAgentCompleted"
@completed="onCompleted"
/>
</div>
</div>
</div>
</div>
@@ -1,26 +1,27 @@
<script setup lang="ts">
/**
* 并行多智能体面板(主组件,可嵌入)。
* 并行多智能体面板(主组件,可嵌入)—— 纯展示 + 暴露方法版本。
*
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
* 聊天记录 + 回显 + 重新分析。
* 「任务名称 / 简介 / 模型」全部由宿主通过 props 传入;本组件不再渲染顶部表单与按钮,
* 只展示「席位状态条 + 并行会话」。宿主自行渲染「重新分析 / 保存」等按钮,并通过模板 ref
* 调用本组件暴露的 `reanalyze()` / `save()` / `cancel()` 等方法。
*
* 并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表照搬圆桌 Step2 并行多席位的
* 深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存聊天记录 + 回显 + 重新分析。
*
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "@/types";
import { useParallelRun } from "@/composables/useParallelRun";
import {
createTask,
ensureTask,
listModels,
listTasks,
updateTask,
type ApiConfig,
type ModelItem,
} from "@/api";
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
import { DEFAULT_BASE_URL } from "@/config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
@@ -37,15 +38,26 @@ const props = withDefaults(
loginPassword?: string;
agents: AgentInput[];
taskId?: string;
/** 任务名称:由宿主传入(可配合 update:taskName 做 v-model)。 */
taskName?: string;
/** 任务简介:由宿主传入(可配合 update:brief 做 v-model)。 */
brief?: string;
/** 本次并行分析使用的模型;空 = 后端回退默认。 */
model?: string;
dark?: boolean;
}>(),
{ dark: true },
{ dark: true, taskName: "", brief: "", model: "" },
);
const emit = defineEmits<{
(e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): void;
/** 回显已保存任务时回传标题,宿主可据此同步自己的输入框。 */
(e: "update:taskName", name: string): void;
/** 回显已保存任务时回传简介。 */
(e: "update:brief", brief: string): void;
/** 单个智能体一分析完就回传它的结果(每个席位完成时各触发一次)。 */
(e: "agent-completed", result: AgentResult): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
}>();
@@ -58,21 +70,26 @@ const cfg = computed<ApiConfig>(() => ({
loginPassword: props.loginPassword,
}));
const run = useParallelRun();
// 单个智能体完成即回传它的结果(一分析完就给宿主)。
const run = useParallelRun({
onAgentSettled: (agentId) => {
const r = getResult(agentId);
if (r) emit("agent-completed", r);
},
});
const { dialogues, seats, running } = run;
const form = reactive({ taskName: "", brief: "" });
// 任务名 / 简介 / 模型全部由宿主 props 提供(本组件不再持有可编辑表单)。
const taskName = computed(() => props.taskName ?? "");
const brief = computed(() => props.brief ?? "");
const model = computed(() => (props.model || "").trim() || undefined);
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);
@@ -81,7 +98,7 @@ const dialogueById = computed(() => {
const hasResults = computed(() => dialogues.value.length > 0);
const canStart = computed(
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
() => !running.value && props.agents.length > 0 && !!(taskName.value.trim() || brief.value.trim()),
);
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
@@ -137,21 +154,43 @@ function getResults(): AgentResult[] {
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() });
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />`
// 宿主自行渲染按钮,调用下面这些方法 / 读取状态:
// panelRef.value.reanalyze() // 开始 / 重新分析
// panelRef.value.save() // 保存 / 更新保存
// panelRef.value.cancel() // 停止
// panelRef.value.loadTask(id) // 回显某条历史
// panelRef.value.running / saving / hasResults / canStart // 按钮态(ref)
defineExpose({
getResults,
getResult,
start: () => onStart(),
reanalyze: () => onStart(),
save: () => onSave(),
cancel: () => run.cancel(),
loadTask: (id: string) => loadTask(id),
refreshHistory: () => refreshHistory(),
history,
running,
saving,
hasResults,
canStart,
errorMsg,
});
/** 用户在某智能体的协作卡片里提交了回答 → 带上回答重跑该智能体。 */
async function onClarify(agentId: string, answer: string) {
errorMsg.value = null;
const brief = `${form.brief}\n\n【用户补充】${answer}`.trim();
const merged = `${brief.value}\n\n【用户补充】${answer}`.trim();
try {
await run.rerunAgent(cfg.value, {
taskName: form.taskName,
brief,
taskName: taskName.value,
brief: merged,
agentId,
model: selectedModel.value || undefined,
model: model.value,
});
emit("completed", getResults());
void autoSave(); // 重跑完也自动存一次
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
@@ -162,26 +201,37 @@ async function onStart() {
errorMsg.value = null;
try {
await run.start(cfg.value, {
taskName: form.taskName,
brief: form.brief,
taskName: taskName.value,
brief: brief.value,
agents: props.agents,
model: selectedModel.value || undefined,
model: model.value,
});
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
void autoSave(); // 分析完自动保存一次
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
/** 一轮分析结束后自动保存一次(有结果才存;失败静默,不打断主流程)。 */
async function autoSave() {
if (!hasResults.value || saving.value) return;
try {
await onSave();
} catch {
/* 自动保存失败不打扰用户,错误已在 onSave 内记录 */
}
}
async function onSave() {
if (saving.value || !hasResults.value) return;
saving.value = true;
errorMsg.value = null;
try {
const title = form.taskName.trim() || "未命名任务";
const title = taskName.value.trim() || "未命名任务";
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a }));
const payload = { title, brief: form.brief, agents: agentsSnapshot, messages: dialogues.value };
const payload = { title, brief: brief.value, agents: agentsSnapshot, messages: dialogues.value };
// 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。
let saved: SavedTask | null;
const id = currentTaskId.value;
@@ -206,14 +256,17 @@ async function onSave() {
async function refreshHistory() {
try {
history.value = await listTasks(cfg.value);
const list = await listTasks(cfg.value);
// 按更新时间倒序,最新的在最前(供「进入默认显示最新一条」用)。
history.value = [...list].sort((a, b) =>
(b.updated_at || b.created_at || "").localeCompare(a.updated_at || a.created_at || ""),
);
} catch {
/* 历史拉取失败不阻断主流程 */
}
}
async function loadTask(id: string) {
showHistory.value = false;
errorMsg.value = null;
try {
// get-or-create:新 id 直接初始化空记录(不 404);已存在则回显其聊天记录。
@@ -224,11 +277,12 @@ async function loadTask(id: string) {
currentTaskId.value = task.id;
selected.value = [];
if (task.messages && task.messages.length) {
form.taskName = task.title ?? "";
form.brief = task.brief ?? "";
// 标题 / 简介由宿主持有:回传让宿主同步自己的输入框(v-model)。
emit("update:taskName", task.title ?? "");
emit("update:brief", task.brief ?? "");
run.hydrate(task.messages);
} else {
// 空记录(新初始化):清空当前会话,等用户填任务 / 开始分析。
// 空记录(新初始化):清空当前会话,等宿主填任务 / 触发分析。
run.reset();
}
emit("update:taskId", task.id);
@@ -237,19 +291,14 @@ async function loadTask(id: string) {
}
}
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(async () => {
await refreshHistory();
if (props.taskId) {
void loadTask(props.taskId);
} else if (history.value.length) {
// 没指定 taskId:默认回显最新一条聊天记录。
void loadTask(history.value[0].id);
}
}
onMounted(() => {
void refreshHistory();
void refreshModels();
if (props.taskId) void loadTask(props.taskId);
});
</script>
@@ -258,98 +307,17 @@ onMounted(() => {
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>
<!-- 顶部表单与按钮已移除:任务名 / 简介 / 模型由宿主 props 传入,
「重新分析 / 保存 / 停止」由宿主调用本组件 expose 出的方法。 -->
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
<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>
<!-- 错误提示(保留,便于宿主无需额外处理也能看到失败原因) -->
<p
v-if="errorMsg"
class="pa-border shrink-0 border-b px-4 py-2 text-xs"
style="color: var(--pa-rose)"
>
{{ errorMsg }}
</p>
<!-- 席位状态条 -->
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
@@ -383,7 +351,7 @@ onMounted(() => {
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-sm pa-text-muted">等待宿主触发「开始 / 重新分析」,并行调用各智能体。</p>
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
</div>
</div>
@@ -18,12 +18,25 @@ function nextId(): number {
return _seq++;
}
export function useParallelRun() {
export interface ParallelRunOptions {
/** 单个智能体进入终态(完成 / 待确认 / 出错)时回调一次,用于「一分析完就回传」。 */
onAgentSettled?: (agentId: string, status: SeatView["status"]) => void;
}
export function useParallelRun(opts: ParallelRunOptions = {}) {
const dialogues = ref<Dialogue[]>([]);
const seats = ref<SeatView[]>([]);
const running = ref(false);
const abortRef = shallowRef<AbortController | null>(null);
// 本轮已「结算」(回调过 onAgentSettled)的 agentId,去重,避免重复回传。
const settled = new Set<string>();
function settleAgent(agentId: string, status: SeatView["status"]) {
if (settled.has(agentId)) return;
settled.add(agentId);
opts.onAgentSettled?.(agentId, status);
}
// 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
const pending = new Map<string, string>(); // agentId -> 待追加文本
let rafScheduled = false;
@@ -93,6 +106,7 @@ export function useParallelRun() {
}
setSeatStatus(ev.agent_id, "done");
dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "done");
break;
}
case "agent_clarification": {
@@ -113,6 +127,7 @@ export function useParallelRun() {
}
setSeatStatus(ev.agent_id, "done");
dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "done");
break;
}
case "agent_error": {
@@ -125,6 +140,7 @@ export function useParallelRun() {
}
setSeatStatus(ev.agent_id, "error");
dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "error");
break;
}
case "all_done":
@@ -188,6 +204,8 @@ export function useParallelRun() {
}
}
dialogues.value = [...dialogues.value];
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态)。
for (const s of seats.value) settleAgent(s.agentId, s.status === "error" ? "error" : "done");
running.value = false;
abortRef.value = null;
}
@@ -218,6 +236,7 @@ export function useParallelRun() {
setSeatStatus(args.agentId, "running");
dialogues.value = [...dialogues.value];
settled.delete(args.agentId); // 该席位重跑,允许再次回传
running.value = true;
const ac = new AbortController();
abortRef.value = ac;
@@ -235,6 +254,8 @@ export function useParallelRun() {
if (dd.confidence === "并行执行中") dd.confidence = "已完成";
}
dialogues.value = [...dialogues.value];
const st = seats.value.find((x) => x.agentId === args.agentId);
settleAgent(args.agentId, st?.status === "error" ? "error" : "done");
running.value = false;
abortRef.value = null;
}
@@ -266,6 +287,7 @@ export function useParallelRun() {
cancel();
pending.clear();
bubbleByAgent.clear();
settled.clear();
dialogues.value = [];
seats.value = [];
}