This commit is contained in:
fengxiangdebaozi committed 2026-06-10 17:01:25 +08:00
commit b8680275e5
31 files changed
+1589 -548

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 佳来模型对话接口 # 佳来模型对话接口
@@ -0,0 +1,437 @@
import { TaskListItem } from './TaskManagement';
/**
* 任务看板假数据 —— 用于在没有后端 `/task/cop-tasks` / `/three/cop-three-tasks`
* 接口时,给「任务分析」「三情分析」两个页面提供演示数据。
*
* 用法:把组件里的 `await service.post(url, payload)` 换成
* `await mockCopTasks('task' | 'sentiment', taskType, payload)`
* 返回结构与真实接口保持一致:`{ data: { records, total } }`。
*/
export type MockColumnType = 'unanalyzed' | 'running' | 'completed';
export type MockDataset = 'task' | 'sentiment';
interface MockTaskSeed {
taskName: string;
overview: string;
taskDirection: string;
taskLevel: number; // 1 一般 / 2 紧急 / 3 加急
taskType: number; // 1 单任务 / 2 场景 / 3 会商 / 4 多智能体 / 5 检索
}
/** 列 → 状态码,与组件内 taskStatusMap 对齐。 */
const COLUMN_STATUS: Record<MockColumnType, number> = {
unanalyzed: 0,
running: 20,
completed: 30,
};
/** 生成一条完整的 TaskListItem,仅覆盖有意义的字段,其余给安全默认值。 */
function makeTask(
id: number,
column: MockColumnType,
seed: MockTaskSeed,
createTime: string,
updateTime: string,
): TaskListItem {
return {
createBy: 1001,
createTime,
deptId: null,
id,
isTop: 0,
issuingDept: '战略规划处',
keywords: null,
overview: seed.overview,
planTaskTop: 0,
platform: null,
ptaskId: null,
recommendedBasis: null,
scene: '',
sourceType: 1,
step: null,
taskContent: seed.overview,
taskDirection: seed.taskDirection,
taskEndDate: '2026-06-30 18:00:00',
taskLevel: seed.taskLevel,
taskName: seed.taskName,
taskNo: `T-${String(id).padStart(6, '0')}`,
taskStartDate: '2026-06-01 09:00:00',
taskStatus: COLUMN_STATUS[column],
taskSuperiorFlag: 0,
taskType: seed.taskType,
column,
updateBy: 1001,
updateTime,
endCount: column === 'completed' ? 1 : 0,
sumAll: 1,
progress: column === 'completed' ? 100 : column === 'running' ? 60 : 0,
taskStatusData: null,
} as TaskListItem;
}
/** 「任务分析」页(业务码 6BF)数据种子。 */
const TASK_SEEDS: Record<MockColumnType, MockTaskSeed[]> = {
unanalyzed: [
{
taskName: '夏季文旅消费季传播任务分析',
overview:
'围绕"夏季文旅消费季"主题,梳理传播目标、核心受众与潜在策略阻力,输出可执行的任务拆解建议。',
taskDirection: '王磊',
taskLevel: 2,
taskType: 1,
},
{
taskName: '新能源补贴政策解读专题',
overview:
'针对新一轮新能源汽车补贴政策,分析公众关注焦点与误读风险点,形成政策解读传播要点清单。',
taskDirection: '李婷',
taskLevel: 1,
taskType: 5,
},
{
taskName: '城市更新片区舆论引导任务',
overview:
'对老城区改造涉及的拆迁、安置等敏感议题进行任务目标拆解,识别需要重点回应的群体诉求。',
taskDirection: '张伟',
taskLevel: 3,
taskType: 2,
},
{
taskName: '高校毕业季就业服务宣传',
overview:
'梳理面向应届毕业生的就业服务政策传播任务,明确分阶段触达节奏与渠道组合。',
taskDirection: '赵敏',
taskLevel: 1,
taskType: 1,
},
{
taskName: '防汛应急科普传播任务',
overview:
'汛期来临前,分析防汛减灾科普内容的传播目标与受众分层,制定预警信息发布预案。',
taskDirection: '陈昊',
taskLevel: 2,
taskType: 1,
},
{
taskName: '乡村振兴成果展示任务',
overview:
'围绕本地乡村振兴典型案例,拆解成果展示传播任务,确定故事化表达与素材采集方向。',
taskDirection: '周琳',
taskLevel: 1,
taskType: 2,
},
],
running: [
{
taskName: '社区养老服务满意度专题分析',
overview:
'正在对社区居家养老服务的群众反馈进行任务分析,重点判断受众诉求与服务短板。',
taskDirection: '孙强',
taskLevel: 2,
taskType: 4,
},
{
taskName: '中小企业纾困政策落地评估',
overview:
'多智能体协同分析纾困政策落地过程中的执行阻力与企业关切,形成阶段性研判。',
taskDirection: '吴静',
taskLevel: 3,
taskType: 4,
},
{
taskName: '城市轨道交通延误舆情研判',
overview:
'针对近期地铁延误事件,正在拆解舆论传播路径与关键节点,评估引导策略有效性。',
taskDirection: '郑凯',
taskLevel: 2,
taskType: 3,
},
{
taskName: '食品安全抽检结果发布任务',
overview:
'正在分析食品安全抽检通报的发布口径与受众反应,优化信息透明度与安抚策略。',
taskDirection: '冯雪',
taskLevel: 1,
taskType: 1,
},
],
completed: [
{
taskName: '春节文化活动传播复盘',
overview:
'已完成春节系列文化活动的传播任务分析,沉淀受众画像与高互动内容规律。',
taskDirection: '黄涛',
taskLevel: 1,
taskType: 3,
},
{
taskName: '医保改革政策宣传任务',
overview:
'已完成医保改革政策的传播目标拆解与受众分层,输出分群体沟通话术。',
taskDirection: '宋佳',
taskLevel: 2,
taskType: 4,
},
{
taskName: '城市营商环境优化宣传',
overview:
'已完成营商环境优化举措的任务分析,明确企业端与公众端的差异化传播重点。',
taskDirection: '高鹏',
taskLevel: 1,
taskType: 2,
},
{
taskName: '老旧小区加装电梯议题分析',
overview:
'已完成加装电梯涉及的邻里诉求与阻力识别,形成多方协商沟通建议。',
taskDirection: '何丽',
taskLevel: 2,
taskType: 3,
},
{
taskName: '生态环保主题宣传周复盘',
overview:
'已完成环保宣传周的传播效果分析,总结渠道贡献度与可复用素材。',
taskDirection: '罗勇',
taskLevel: 1,
taskType: 1,
},
{
taskName: '青年人才引进政策解读',
overview:
'已完成人才引进政策的受众判断与策略阻力分析,输出落地传播节奏表。',
taskDirection: '谭芳',
taskLevel: 1,
taskType: 5,
},
],
};
/** 「三情分析」页(业务码 3Q)数据种子。 */
const SENTIMENT_SEEDS: Record<MockColumnType, MockTaskSeed[]> = {
unanalyzed: [
{
taskName: '暑期旅游热点情绪监测',
overview:
'对暑期热门景区相关讨论进行情绪与倾向分析,识别游客吐槽集中点与正向口碑话题。',
taskDirection: '王磊',
taskLevel: 2,
taskType: 1,
},
{
taskName: '电价调整公众情绪研判',
overview:
'围绕居民阶梯电价调整,分析公众情绪走向与主要担忧,判断需要重点疏导的群体。',
taskDirection: '李婷',
taskLevel: 3,
taskType: 5,
},
{
taskName: '校园周边交通安全舆情',
overview:
'对校园周边交通安全话题进行三情分析,梳理家长群体情绪与诉求强度。',
taskDirection: '张伟',
taskLevel: 2,
taskType: 1,
},
{
taskName: '景区门票预约制讨论分析',
overview:
'分析门票预约制相关讨论的情绪分布,识别便利性与公平性两类核心关切。',
taskDirection: '赵敏',
taskLevel: 1,
taskType: 2,
},
{
taskName: '夜间经济噪音投诉情绪',
overview:
'对夜市、夜间演出引发的噪音投诉进行情绪分析,区分居民与商户的不同立场。',
taskDirection: '陈昊',
taskLevel: 1,
taskType: 1,
},
],
running: [
{
taskName: '台风预警信息传播情绪追踪',
overview:
'正在分析台风预警发布后的公众情绪变化,评估恐慌情绪与配合度的动态走势。',
taskDirection: '孙强',
taskLevel: 3,
taskType: 4,
},
{
taskName: '学区划分调整舆论情绪',
overview:
'多智能体协同追踪学区调整话题的情绪热度与对立观点,研判潜在升级风险。',
taskDirection: '吴静',
taskLevel: 2,
taskType: 4,
},
{
taskName: '网络热点事件情绪态势',
overview:
'正在对近期网络热点事件进行三情分析,识别情绪峰值节点与关键意见领袖。',
taskDirection: '郑凯',
taskLevel: 2,
taskType: 3,
},
],
completed: [
{
taskName: '春运出行体验情绪复盘',
overview:
'已完成春运期间出行体验的情绪与倾向分析,沉淀满意度变化与高频抱怨主题。',
taskDirection: '黄涛',
taskLevel: 1,
taskType: 3,
},
{
taskName: '惠民政策落地情绪评估',
overview:
'已完成惠民政策落地后的公众情绪评估,输出正向反馈与改进诉求清单。',
taskDirection: '宋佳',
taskLevel: 2,
taskType: 4,
},
{
taskName: '城市内涝应对情绪分析',
overview:
'已完成强降雨内涝事件的三情分析,总结情绪演化曲线与有效安抚措施。',
taskDirection: '高鹏',
taskLevel: 2,
taskType: 1,
},
{
taskName: '景区限流措施满意度分析',
overview:
'已完成节假日景区限流措施的情绪分析,明确游客接受度与体验改进方向。',
taskDirection: '何丽',
taskLevel: 1,
taskType: 2,
},
{
taskName: '公共交通票价听证情绪',
overview:
'已完成票价听证会相关讨论的情绪与立场分析,输出分群体沟通建议。',
taskDirection: '罗勇',
taskLevel: 1,
taskType: 5,
},
],
};
const SEEDS: Record<MockDataset, Record<MockColumnType, MockTaskSeed[]>> = {
task: TASK_SEEDS,
sentiment: SENTIMENT_SEEDS,
};
/** 给同一列里的卡片生成错落有致的时间,便于「按时间倒序」展示。 */
function buildTimes(index: number): { createTime: string; updateTime: string } {
const day = String(28 - index).padStart(2, '0');
const hour = String(9 + (index % 8)).padStart(2, '0');
return {
createTime: `2026-05-${day} ${hour}:15:00`,
updateTime: `2026-06-0${(index % 9) + 1} ${hour}:40:00`,
};
}
/** 数据集 → 列 → 完整任务列表(按 id 区分,避免两页 key 冲突)。 */
function buildPool(dataset: MockDataset, column: MockColumnType): TaskListItem[] {
const base = dataset === 'task' ? 10000 : 20000;
const columnOffset = column === 'unanalyzed' ? 0 : column === 'running' ? 300 : 600;
return SEEDS[dataset][column].map((seed, index) => {
const { createTime, updateTime } = buildTimes(index);
return makeTask(base + columnOffset + index, column, seed, createTime, updateTime);
});
}
interface MockCopTasksPayload {
content?: string;
pageNum?: number;
pageSize?: number;
taskStatus?: number[];
}
/** 根据请求里的 taskStatus 数组反推当前查询的是哪一列。 */
function resolveColumn(taskStatus?: number[]): MockColumnType {
const list = Array.isArray(taskStatus) ? taskStatus : [];
if (list.includes(20)) return 'running';
if (list.includes(0) || list.includes(10)) return 'unanalyzed';
return 'completed';
}
/**
* 模拟 `/task/cop-tasks` / `/three/cop-three-tasks` 接口的 body:
* 按列返回分页后的假数据,支持按 `content` 对任务名/摘要做模糊过滤。
*/
function buildCopTasksBody(dataset: MockDataset, payload: MockCopTasksPayload) {
const { content = '', pageNum = 1, pageSize = 10, taskStatus } = payload;
const column = resolveColumn(taskStatus);
let pool = buildPool(dataset, column);
const keyword = content.trim();
if (keyword) {
pool = pool.filter(
(item) =>
item.taskName.includes(keyword) || item.overview.includes(keyword),
);
}
const total = pool.length;
const start = (pageNum - 1) * pageSize;
const records = pool.slice(start, start + pageSize);
return { code: 0, message: 'ok', data: { records, total } };
}
/** 命中本 mock 的接口路径 → 对应数据集。 */
const MOCK_ROUTES: Array<{ match: string; dataset: MockDataset }> = [
{ match: '/three/cop-three-tasks', dataset: 'sentiment' },
{ match: '/task/cop-tasks', dataset: 'task' },
];
/** 判断某个请求 url 是否应走假数据。 */
export function matchTaskBoardMock(url?: string): MockDataset | null {
if (!url) return null;
const hit = MOCK_ROUTES.find((route) => url.includes(route.match));
return hit ? hit.dataset : null;
}
/**
* 临时假数据 axios adapter:命中任务看板接口时直接返回 mock body,不发真实请求。
* 仅用于演示,撤掉假数据时删除 request.ts 中对它的引用即可。
*/
export function taskBoardMockAdapter(config: {
url?: string;
data?: unknown;
}): Promise<{ data: unknown; status: number; statusText: string; headers: Record<string, unknown>; config: unknown }> {
const dataset = matchTaskBoardMock(config.url) ?? 'task';
let payload: MockCopTasksPayload = {};
try {
payload =
typeof config.data === 'string'
? JSON.parse(config.data)
: ((config.data as MockCopTasksPayload) ?? {});
} catch {
payload = {};
}
const body = buildCopTasksBody(dataset, payload);
return new Promise((resolve) => {
// 模拟网络延迟,让 Loading 态可见。
setTimeout(() => {
resolve({
data: body,
status: 200,
statusText: 'OK',
headers: {},
config,
});
}, 280);
});
}
+6
View File
@@ -1,4 +1,6 @@
import axios from 'axios' import axios from 'axios'
// === 临时假数据(任务分析 / 三情分析看板):撤掉时删除本 import 及下方 request 拦截器中的对应块即可 ===
import { matchTaskBoardMock, taskBoardMockAdapter } from '@/strategy-components/components/task-components/task-mock-data'
// axios.interceptors // axios.interceptors
const service = axios.create({ const service = axios.create({
// baseURL: '', // url = base url + request url // baseURL: '', // url = base url + request url
@@ -31,6 +33,10 @@ service.interceptors.response.use(
) )
service.interceptors.request.use( service.interceptors.request.use(
config => { config => {
// === 临时假数据:任务看板两个接口直接走本地 mock,不发真实请求 ===
if (matchTaskBoardMock(config.url)) {
config.adapter = taskBoardMockAdapter as any;
}
// let token: any = localStorage.getItem('token'); // let token: any = localStorage.getItem('token');
config.headers['Authorization'] = `Bearer admin`; config.headers['Authorization'] = `Bearer admin`;
// if (token) { // if (token) {
@@ -7,10 +7,12 @@ one stream and never parses LangGraph internals.
Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` = Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` =
``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` / ``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` /
``bash``) so a seat can neither write files (parallel writes would clash) nor ``bash`` / ``ask_clarification``) so a seat can neither write files (parallel
dispatch / run scripts (a weak model fed "总控 / 业务链条" framing would otherwise writes would clash), dispatch / run scripts (a weak model fed "总控 / 业务链条"
mimic the controller and "派活"). Read-only tools + ``ask_clarification`` stay on, framing would otherwise mimic the controller and "派活"), nor pause to ask the
so the step bar and clarification cards still work. user for help (this panel is an unattended parallel fan-out — a seat short on
info makes a reasonable assumption and states it in its answer instead of
interrupting). Read-only tools stay on, so the step bar still works.
Unified event protocol (one JSON object per ``data:`` frame):: Unified event protocol (one JSON object per ``data:`` frame)::
@@ -58,18 +60,21 @@ router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
# Soft cap on parallel degree — protects remote model concurrency + sandbox. # Soft cap on parallel degree — protects remote model concurrency + sandbox.
MAX_PARALLEL_AGENTS = 8 MAX_PARALLEL_AGENTS = 8
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架; # 禁三类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那 # ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务 # 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
# 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本 # 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本
# 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用 # 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用
# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file / # glob / bash 翻找派活脚本)。③**用户协助**(ask_clarification)—— 本面板是「无人值守」
# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。 # 的并行扇出,没有让单个席位中途打断、向用户要澄清的交互位;禁掉后席位遇到信息不足时
# 自行做合理假设并把假设写进正文交付,而不是挂起等人。物理禁掉这三类即可根治。其余只读
# 工具(ls / read_file / 检索)保留:既不互相打架,又能正常出步骤条。
_EXCLUDED_TOOLS: list[str] = [ _EXCLUDED_TOOLS: list[str] = [
"write_file", # 并行不互相写文件打架 "write_file", # 并行不互相写文件打架
"str_replace", "str_replace",
"agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控) "agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控)
"bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活) "bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活)
"ask_clarification", # 禁「用户协助」:并行无人值守,不向用户要澄清,自己合理假设并交付
] ]
@@ -276,6 +281,14 @@ def _build_task_message(task_name: str, brief: str) -> str:
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用" "调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。" "任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
) )
# 无人值守护栏:本轮并行执行没有让你中途向用户要澄清的交互位(已禁用用户协助)。遇到
# 信息不足时,请基于已有信息做出**合理假设**并在正文中**注明你的假设**,照常给出交付,
# 不要反问用户、不要把交付挂起等待补充。
parts.append(
"【无人值守】本轮为并行无人值守执行,没有与你来回澄清的环节。若信息不足,请自行做出"
"**合理假设**并在交付中**明确写出你假设了什么**,然后照常完成分析;不要反问用户、不要"
"要求补充信息后再继续,也不要把结论挂起等待确认。"
)
return "\n\n".join(parts) return "\n\n".join(parts)
+14 -1
View File
@@ -25,11 +25,23 @@ pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs} + dist/parallel-a
## 嵌入其它 Vue 项目 ## 嵌入其它 Vue 项目
两种方式(详见 [docs/07-使用说明.md](./docs/07-使用说明.md)):
**A. 按包引入**(已 `build:lib`):
```ts ```ts
import { ParallelAgentsPanel } from "parallel-agents-vue"; import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css"; import "parallel-agents-vue/style.css";
``` ```
**B. 直接拷源码**(适合要改源码的人):把整个 **`src/parallel-agents/` 文件夹**拷进你的项目 `src/` 下,
样式由其 `index.ts` 自动引入,**无需 Tailwind / 任何 CSS 框架**:
```ts
import { ParallelAgentsPanel } from "@/parallel-agents"; // 或 "./parallel-agents"
```
> 仅需 `vue` 与 `markdown-it` 两个运行依赖。
```vue ```vue
<!-- 放进你自己的抽屉 / 弹窗里,面板会自适应容器(h-full w-full)。 <!-- 放进你自己的抽屉 / 弹窗里,面板会自适应容器(h-full w-full)。
接口地址走配置文件默认(src/config.ts),登录只需把用户名传进来(免密自动登录)。 --> 接口地址走配置文件默认(src/config.ts),登录只需把用户名传进来(免密自动登录)。 -->
@@ -76,4 +88,5 @@ import "parallel-agents-vue/style.css";
- `@saved="(task) => ..."` —— 保存成功(含新建/更新)后回传 `SavedTask`。 - `@saved="(task) => ..."` —— 保存成功(含新建/更新)后回传 `SavedTask`。
- `@update:taskId="(id) => ..."` —— 当前任务 id 变化(保存 / 加载历史时)。 - `@update:taskId="(id) => ..."` —— 当前任务 id 变化(保存 / 加载历史时)。
> 样式作用域全部包在根类 `.parallel-agents` 下,且**不引入 Tailwind preflight**,不会污染宿主全局样式。 > 样式作用域全部包在根类 `.parallel-agents` 下,**不依赖 Tailwind**(布局类是面板自带的作用域工具类子集),
> 不会污染宿主全局样式。
@@ -84,15 +84,15 @@ pnpm build:lib
## PA-EMBED-06 · 不污染宿主全局样式(P0) ## PA-EMBED-06 · 不污染宿主全局样式(P0)
**目的**:核对**未引入 Tailwind preflight**(不重置宿主全局标签样式)。 **目的**:核对面板**不依赖 Tailwind**、不重置宿主全局标签样式(无任何裸标签选择器 / reset)。
**操作步骤** **操作步骤**
1. 在一个有自己样式的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。 1. 在一个有自己样式、且**没装 Tailwind** 的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。
**预期结果** **预期结果**
- 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(没有被 Tailwind 的 reset 抹平)。 - 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(面板样式无全局 reset、无裸标签规则)。
- 面板内部样式正常(不依赖宿主提供 Tailwind)。 - 面板内部样式正常(**不需要宿主提供 Tailwind**;布局类是面板自带的作用域工具类子集)。
- 类名都在 `.parallel-agents` 作用域下,不与宿主类名冲突。 - 所有规则都在 `.parallel-agents` 作用域下,不与宿主类名冲突。
--- ---
+230 -34
View File
@@ -4,6 +4,14 @@
`ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。 `ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。
宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。 宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。
> **⚠️ 契约变更(纯展示版)**:面板**不再自带顶部表单与按钮**。
> - **任务名称 / 简介 / 模型** 全部由宿主通过 props 传入(`taskName` / `brief` 支持 `v-model`);
> - **「重新分析 / 保存 / 停止」按钮由宿主自己渲染**,通过模板 ref 调用面板 `defineExpose` 出的
> `reanalyze()` / `save()` / `cancel()` 等方法(见 [§六·二](#六二暴露的方法与状态模板-ref));
> - 面板本体只展示「席位状态条 + 并行会话 + 错误提示」。
>
> 完整的宿主参考实现见仓库 `src/App.vue`(弹窗里宿主自渲染工具条 + 调用 ref 方法)。
--- ---
## 一、要引入的东西(就这两样) ## 一、要引入的东西(就这两样)
@@ -12,6 +20,8 @@
2. **样式**:`parallel-agents-vue/style.css`(一次性引入即可,样式作用域全在 `.parallel-agents` 下) 2. **样式**:`parallel-agents-vue/style.css`(一次性引入即可,样式作用域全在 `.parallel-agents` 下)
> 依赖说明:`vue ^3.4`(宿主提供,库不打包 vue);`markdown-it` 已打进库内,无需宿主安装。 > 依赖说明:`vue ^3.4`(宿主提供,库不打包 vue);`markdown-it` 已打进库内,无需宿主安装。
> **无需 Tailwind 或任何 CSS 框架**:布局样式已内置为 `parallel-agents.css` 末尾的「最小工具类子集」
> (全部作用域在 `.parallel-agents` 下,不污染宿主)。宿主项目**不用**装 / 配 Tailwind。
--- ---
@@ -33,21 +43,32 @@
import "parallel-agents-vue/style.css"; import "parallel-agents-vue/style.css";
``` ```
### 方式 B:直接拷贝源码(无需发包) ### 方式 B:直接拷贝源码(无需发包,**推荐给需要改源码的人**)
把本项目 `src/` 下这些拷进宿主项目(保持相对结构),按相对路径引入 `ParallelAgentsPanel.vue` + 所有实现已收进**一个自包含文件夹** `src/parallel-agents/`。把**整个文件夹**拷进宿主项目的 `src/`
`styles/parallel-agents.css` 即可: 下即可,内部全是相对路径引用、不依赖任何构建别名:
``` ```
src/ src/parallel-agents/ ← 整个文件夹拷走即用
components/ ParallelAgentsPanel.vue · MessageBubble.vue · MessageStepsCard.vue index.ts 入口:自动引样式 + 导出组件/类型
ParallelSeatStrip.vue · Avatar.vue · MarkdownContent.vue ParallelAgentsPanel.vue 主组件
composables/ useParallelRun.ts ParallelSeatStrip.vue · MessageBubble.vue · MessageStepsCard.vue · StepIcon.vue
lib/ avatar-constants.ts ClarificationCard.vue · MarkdownContent.vue · Avatar.vue (子组件)
styles/ parallel-agents.css useParallelRun.ts 并行运行编排
api.ts · config.ts · types.ts api.ts · types.ts · config.ts · avatar-constants.ts · step-display.ts
parallel-agents.css 作用域样式(深色 token + 语义类 + 工具类子集,无 Tailwind)
README.md 本文件夹的速用说明
``` ```
引入(样式由 `index.ts` 自动引入,无需手动 import css):
```ts
import { ParallelAgentsPanel } from "@/parallel-agents";
// 或相对路径: import { ParallelAgentsPanel } from "./parallel-agents";
```
宿主需有 `markdown-it` 依赖(`pnpm add markdown-it`),并保证构建支持 `.vue`(Vite/@vitejs/plugin-vue)。 宿主需有 `markdown-it` 依赖(`pnpm add markdown-it`),并保证构建支持 `.vue`(Vite/@vitejs/plugin-vue)。
**不需要 Tailwind**。
--- ---
@@ -55,8 +76,11 @@ src/
### 局部引入(推荐) ### 局部引入(推荐)
宿主持有任务名 / 简介 / 模型,并自渲染按钮调用面板暴露的方法:
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue";
import { ParallelAgentsPanel } from "parallel-agents-vue"; import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css"; import "parallel-agents-vue/style.css";
@@ -64,15 +88,43 @@ const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" }, { agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" }, { agent_id: "roundtable-risk-review", name: "风险审查" },
]; ];
// 宿主持有这三项(面板不再渲染输入框)
const taskName = ref("");
const brief = ref("");
const model = ref("");
// 模板 ref:拿到面板暴露的方法 / 状态
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
</script> </script>
<template> <template>
<div style="height: 80vh"> <div style="display: flex; flex-direction: column; height: 80vh">
<ParallelAgentsPanel :username="'demo'" :agents="agents" /> <!-- 宿主自己的工具条 -->
<input v-model="taskName" placeholder="任务名称" />
<textarea v-model="brief" placeholder="任务简介" />
<button :disabled="!panel?.canStart" @click="panel?.reanalyze()">
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
</button>
<button :disabled="panel?.saving || !panel?.hasResults" @click="panel?.save()">保存</button>
<!-- 面板:纯展示,需父容器给高度 -->
<ParallelAgentsPanel
ref="panel"
style="flex: 1; min-height: 0"
:username="'demo'"
:agents="agents"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
/>
</div> </div>
</template> </template>
``` ```
> 面板暴露出的 `running` / `saving` / `hasResults` / `canStart` 等是 ref,**在组件实例上会自动解包**,
> 模板里直接 `panel?.canStart` 读,**无需 `.value`**。
### 全局注册(可选) ### 全局注册(可选)
```ts ```ts
@@ -90,8 +142,20 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
```vue ```vue
<YourDrawer v-model="open" size="large"> <YourDrawer v-model="open" size="large">
<div style="height: 86vh"> <div style="display: flex; flex-direction: column; height: 86vh">
<ParallelAgentsPanel :username="currentUsername" :agents="agents" @saved="onSaved" /> <!-- 宿主工具条:任务名/简介/模型 + 按钮,由你自己渲染 -->
<MyToolbar v-model:task-name="taskName" v-model:brief="brief"
@start="panel?.reanalyze()" @save="panel?.save()" />
<ParallelAgentsPanel
ref="panel"
style="flex: 1; min-height: 0"
:username="currentUsername"
:agents="agents"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
@saved="onSaved"
/>
</div> </div>
</YourDrawer> </YourDrawer>
``` ```
@@ -122,14 +186,66 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPan
| `getToken` | `() => string \| Promise<string>` | | 动态取 token | | `getToken` | `() => string \| Promise<string>` | | 动态取 token |
| `loginPassword` | `string` | | 仅当后端开启口令门时需要 | | `loginPassword` | `string` | | 仅当后端开启口令门时需要 |
| `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 | | `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 |
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) | | `taskName` | `string` | | **任务名称(宿主持有)**,支持 `v-model:task-name` |
| `brief` | `string` | | **任务简介(宿主持有)**,支持 `v-model:brief` |
| `model` | `string` | | 模型名(宿主持有;空 = 默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) | | `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
> `taskName` / `brief` 用 `v-model` 是为了**回显**:加载已保存任务时,面板会通过 `update:taskName` /
> `update:brief` 把存档里的标题 / 简介回传给宿主,宿主据此同步自己的输入框。
`AgentInput`/`SavedTask`/`AgentResult` 等类型也从包里导出: `AgentInput`/`SavedTask`/`AgentResult` 等类型也从包里导出:
`import type { AgentInput, SavedTask, AgentResult } from "parallel-agents-vue"`。 `import type { AgentInput, SavedTask, AgentResult } from "parallel-agents-vue"`。
--- ---
## 六·二、暴露的方法与状态(模板 ref)
面板通过 `defineExpose` 暴露下列**方法**与**状态**,供宿主用模板 ref 调用 / 读取。
拿 ref:`const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null)`,模板 `<ParallelAgentsPanel ref="panel" .../>`。
**方法**
| 方法 | 等价于 | 说明 |
| --------------------- | ----------------- | -------------------------------------------- |
| `reanalyze()` | 旧「重新分析」按钮 | 用当前 `taskName`/`brief`/`model` 开始 / 重跑一轮 |
| `start()` | 同 `reanalyze()` | 别名,语义同上 |
| `save()` | 旧「保存」按钮 | 保存 / 更新当前聊天记录(按 `taskId` upsert)|
| `cancel()` | 旧「停止」按钮 | 中止本轮并行运行 |
| `loadTask(id)` | 旧「历史」选择 | 加载并回显某条任务(同时回传标题/简介) |
| `refreshHistory()` | — | 重新拉取历史列表(结果在 `history` 里) |
| `getResults()` | — | 取所有智能体返回内容 `AgentResult[]` |
| `getResult(id)` | — | 取某个智能体返回内容 `AgentResult \| null` |
**状态(ref,组件实例上自动解包,模板里直接读无需 `.value`)**
| 状态 | 类型 | 说明 |
| -------------- | ---------------- | ----------------------------------- |
| `running` | `boolean` | 是否正在并行运行(用于切「停止」按钮)|
| `saving` | `boolean` | 是否正在保存 |
| `hasResults` | `boolean` | 是否已有结果(「开始」/「重新」文案 & 保存可用)|
| `canStart` | `boolean` | 是否满足开始条件(有 agents + 任务名或简介)|
| `history` | `SavedTask[]` | 已保存任务列表(宿主可自建历史下拉)|
| `errorMsg` | `string \| null` | 最近一次错误(面板内也会展示一条) |
```vue
<script setup lang="ts">
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
// JS 里读 ref 状态要带 .value:
function onClickStart() {
if (panel.value?.canStart) panel.value.reanalyze();
}
</script>
<template>
<!-- 模板里自动解包,直接读 -->
<button :disabled="!panel?.canStart" @click="panel?.reanalyze()">
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
</button>
</template>
```
---
## 六·一、每个参数怎么传(逐个举例) ## 六·一、每个参数怎么传(逐个举例)
### `agents`(必填)——「智能体参数」怎么传 ### `agents`(必填)——「智能体参数」怎么传
@@ -171,11 +287,24 @@ const agents = [
<ParallelAgentsPanel :get-token="() => authStore.getAccessToken()" :agents="agents" /> <ParallelAgentsPanel :get-token="() => authStore.getAccessToken()" :agents="agents" />
``` ```
### `model`——指定模型(不传走默认/后端回退;也可在面板右上角下拉切) ### `model`——指定模型(宿主持有;不传走默认/后端回退)
```vue ```vue
<ParallelAgentsPanel :model="'deepseek-chat'" :username="'demo'" :agents="agents" /> <ParallelAgentsPanel :model="model" :username="'demo'" :agents="agents" />
``` ```
> 模型下拉已移到宿主侧:宿主自己渲染下拉(可调后端 `/api/models`),把选中的模型名用 `:model` 传进来。
### `taskName` / `brief`——任务名 / 简介(宿主持有,支持 v-model)
```vue
<ParallelAgentsPanel
v-model:task-name="taskName"
v-model:brief="brief"
:username="'demo'"
:agents="agents"
/>
```
> 用 `v-model` 是为了**回显**:加载已保存任务时面板会把存档标题 / 简介回传给宿主。
### `dark`——深色(默认 true,自带成色) ### `dark`——深色(默认 true,自带成色)
@@ -208,6 +337,11 @@ const agents: AgentInput[] = [
{ agent_id: "roundtable-risk-review", name: "风险审查" }, { agent_id: "roundtable-risk-review", name: "风险审查" },
]; ];
const taskId = ref("task-001"); const taskId = ref("task-001");
const taskName = ref("");
const brief = ref("");
const model = ref("deepseek-chat");
const panel = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
function onSaved(t: SavedTask) { function onSaved(t: SavedTask) {
console.log("已保存", t.id); console.log("已保存", t.id);
} }
@@ -217,13 +351,26 @@ function onCompleted(results: AgentResult[]) {
</script> </script>
<template> <template>
<div style="height: 86vh"> <div style="display: flex; flex-direction: column; height: 86vh">
<!-- 宿主工具条 -->
<input v-model="taskName" placeholder="任务名称" />
<textarea v-model="brief" placeholder="任务简介" />
<button v-if="!panel?.running" :disabled="!panel?.canStart" @click="panel?.reanalyze()">
{{ panel?.hasResults ? "重新分析" : "开始分析" }}
</button>
<button v-else @click="panel?.cancel()">停止</button>
<button :disabled="panel?.saving || !panel?.hasResults" @click="panel?.save()">保存</button>
<ParallelAgentsPanel <ParallelAgentsPanel
ref="panel"
style="flex: 1; min-height: 0"
:base-url="'http://localhost:8001'" :base-url="'http://localhost:8001'"
:username="'demo'" :username="'demo'"
:agents="agents" :agents="agents"
:task-id="taskId" :task-id="taskId"
:model="'deepseek-chat'" v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
:dark="true" :dark="true"
@saved="onSaved" @saved="onSaved"
@update:taskId="(id) => (taskId = id)" @update:taskId="(id) => (taskId = id)"
@@ -237,9 +384,12 @@ function onCompleted(results: AgentResult[]) {
| 事件 | 载荷 | 触发时机 | | 事件 | 载荷 | 触发时机 |
| ----------------- | ------------------ | --------------------------------------- | | ----------------- | ------------------ | --------------------------------------- |
| `saved` | `SavedTask` | 保存成功(新建或更新)后 | | `saved` | `SavedTask` | 保存成功(新建或更新)后;**含自动保存** |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) | | `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 | | `update:taskName` | `string` | 加载已保存任务回显标题(配合 `v-model:task-name`)|
| `update:brief` | `string` | 加载已保存任务回显简介(配合 `v-model:brief`)|
| `agent-completed` | `AgentResult` | **每个**智能体一分析完就回传它单个的结果(各席位各触发一次)|
| `completed` | `AgentResult[]` | 一轮并行分析**全部**结束时,回传所有智能体返回内容 |
```vue ```vue
<ParallelAgentsPanel <ParallelAgentsPanel
@@ -254,9 +404,22 @@ function onCompleted(results: AgentResult[]) {
## 七·一、如何获取模型返回的内容 ## 七·一、如何获取模型返回的内容
每个智能体(模型)返回的正文是 `AgentResult.content`。两种方式,任选其一: 每个智能体(模型)返回的正文是 `AgentResult.content`。组件会**自动把结果推给宿主**,无需轮询:
**A. 事件**:监听 `@completed`,一轮分析结束时自动回传 `AgentResult[]`: **A·1. 单个完成就推**:监听 `@agent-completed`,**哪个智能体先分析完就先回传哪个**(各触发一次):
```vue
<ParallelAgentsPanel
:username="'demo'"
:agents="agents"
@agent-completed="(r) => {
// r: { agent_id, name, content, status } —— 单个智能体
console.log('完成一个:', r.name, '→', r.content);
}"
/>
```
**A·2. 全部完成再推一次**:监听 `@completed`,一轮分析**全部**结束时回传 `AgentResult[]`:
```vue ```vue
<ParallelAgentsPanel <ParallelAgentsPanel
@@ -269,6 +432,8 @@ function onCompleted(results: AgentResult[]) {
/> />
``` ```
> 两个事件可同时用:`agent-completed` 做「来一个渲染一个」的增量更新,`completed` 做「全部到齐」的汇总。
**B. 方法(模板 ref)**:随时主动取(不必等事件): **B. 方法(模板 ref)**:随时主动取(不必等事件):
```vue ```vue
@@ -294,14 +459,25 @@ function readOne(id: string) {
``` ```
`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。 `AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。
`defineExpose` 还暴露了 `start()` / `cancel()`,可由宿主代替按钮触发开始 / 停止。 `status` 取值:`pending | running | done | clarify | error`。**`clarify` = 该智能体在等用户协助
(待确认),不是已完成**——宿主判断「是否全部完成」时应排除 `clarify`(例如
`results.every(r => r.status === "done")`),不要把待协助当成 done。
`defineExpose` 还暴露了 `reanalyze()` / `start()` / `save()` / `cancel()` / `loadTask(id)` 等方法及
`running` / `saving` / `hasResults` / `canStart` / `history` 等状态,完整清单见 [§六·二](#六二暴露的方法与状态模板-ref)。
## 七·二、如何保存 + 回显聊天记录 ## 七·二、如何保存 + 回显聊天记录
**保存**:面板里点「保存」即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。 **保存**:宿主调用 `panel.save()` 即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。
传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端 传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端
生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。 生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。
> **自动保存**:每轮分析(含单席位重跑)**结束后会自动保存一次**,宿主无需手动点保存也不丢记录;
> 自动保存同样走 upsert,并照常触发 `saved` / `update:taskId` 事件(首轮会回传新生成的 id)。
> `panel.save()` 仍保留,供宿主想随时手动存档时调用。
> **进入默认显示最新**:组件挂载时若**没传 `taskId`**,会自动加载并回显**该用户最新的一条**聊天记录
> (历史按更新时间倒序,取第一条);传了 `taskId` 则优先回显指定那条。
**回显(两种方式)**: **回显(两种方式)**:
```vue ```vue
@@ -316,7 +492,20 @@ const taskId = ref<string>(""); // 留空=新任务
taskId.value = "task-001"; // 改了之后面板自动回显 task-001 taskId.value = "task-001"; // 改了之后面板自动回显 task-001
``` ```
> 方式 2:面板**右上角内置「历史」下拉**——点开选一条历史任务即回显,**无需写任何代码**。 > 方式 2:**历史下拉已移到宿主侧**(面板不再内置)。宿主读 `panel.history`(`SavedTask[]`)自建下拉,
> 选中某条时调 `panel.loadTask(id)` 回显;面板会通过 `update:taskName` / `update:brief` 把存档标题 / 简介
> 回传给宿主,配合 `v-model` 同步宿主自己的输入框。需要刷新列表调 `panel.refreshHistory()`。
```vue
<template>
<!-- 宿主自建历史下拉 -->
<select @change="(e) => panel?.loadTask((e.target as HTMLSelectElement).value)">
<option v-for="t in panel?.history || []" :key="t.id" :value="t.id">
{{ t.title || "未命名任务" }}
</option>
</select>
</template>
```
要点:先**保存过**、再用**同一个 taskId**(或从历史里选)才会有内容回显;没存过的新 id 第一次进去是空白 要点:先**保存过**、再用**同一个 taskId**(或从历史里选)才会有内容回显;没存过的新 id 第一次进去是空白
(后台会探测一次 `GET /…/{id}` 返回 404,属正常,面板静默处理)。 (后台会探测一次 `GET /…/{id}` 返回 404,属正常,面板静默处理)。
@@ -325,20 +514,27 @@ taskId.value = "task-001"; // 改了之后面板自动回显 task-
## 八、交互要点(与圆桌 Step2 一致) ## 八、交互要点(与圆桌 Step2 一致)
- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出。运行期间禁用**写文件**(`write_file`/ - 宿主备好「任务名称 + 简介(+ 模型)」props → 调 `panel.reanalyze()` → 各智能体**并行**输出。
`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,防子智能体模仿总控调度);检索、查看、 运行期间禁用**写文件**(`write_file`/`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,
`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常显示**,每个智能体只做自己那份分析。 防子智能体模仿总控调度);检索、查看、`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常
显示**,每个智能体只做自己那份分析。
- 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。 - 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。
- 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。 - 顶部席位状态条显示每个智能体的进行中 / 完成 / **待协助** / 出错;待协助数量会单独标「N 待协助」。
- 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。 - **用户协助(澄清)**:智能体要澄清时该席位变「待协助」(不是完成),气泡里弹协作卡片。多个智能体
切换作用于**下一次**「开始分析 / 重新分析」,运行中禁用。 同时待协助时**可分别点协助、互不阻塞**(协助 A 不会禁用 B/C 的卡片);提交回答后该席位自动带上
- 「保存」存档;「历史」里选回**回显**;「重新分析」清空重跑。 补充重跑。
- **任务名 / 简介 / 模型 / 按钮 / 历史下拉全部在宿主侧**:模型空 = `model` prop / 配置 / 后端回退;
模型作用于**下一次**`reanalyze()`。
- `panel.save()` 存档;`panel.loadTask(id)` 回显;`panel.reanalyze()` 清空重跑;`panel.cancel()` 停止。
- **每轮分析结束自动保存一次**;挂载时没传 `taskId` 会**默认回显最新一条**记录。
- 结果自动推给宿主:`@agent-completed`(来一个推一个)+ `@completed`(全部到齐再推一次)。
--- ---
## 九、注意事项 ## 九、注意事项
- **不会污染宿主全局样式**:库未引入 Tailwind preflight,类名都在 `.parallel-agents` 作用域内。 - **不会污染宿主全局样式**:**不依赖 Tailwind**,布局类已内置为作用域工具类子集,所有规则都在
`.parallel-agents` 下,绝不外泄到宿主页面。
- **深色自带成色**:面板始终深蓝深色,不随宿主明暗切换(与圆桌 Step2 观感一致)。 - **深色自带成色**:面板始终深蓝深色,不随宿主明暗切换(与圆桌 Step2 观感一致)。
- **跨域**:若宿主与后端不同源,后端需允许该来源的 CORS + 带 `credentials`;登录端点公共且 CSRF 豁免, - **跨域**:若宿主与后端不同源,后端需允许该来源的 CORS + 带 `credentials`;登录端点公共且 CSRF 豁免,
业务请求用 Bearer 头已绕过 CSRF。 业务请求用 Bearer 头已绕过 CSRF。
+47 -33
View File
@@ -23,7 +23,7 @@
| 框架 | Vue 3(`<script setup>` + Composition API) | | 框架 | Vue 3(`<script setup>` + Composition API) |
| 构建 | Vite 6(dev app + `build:lib` 组件库双产物) | | 构建 | Vite 6(dev app + `build:lib` 组件库双产物) |
| 语言 | TypeScript(`strict` + `noUnusedLocals/Parameters`) | | 语言 | TypeScript(`strict` + `noUnusedLocals/Parameters`) |
| 样式 | TailwindCSS v4(**仅 utilities,不引 preflight**)+ 作用域 `.pa-*` token | | 样式 | **零 CSS 框架**:作用域 `.pa-*` token + 内置「最小工具类子集」(不依赖 Tailwind) |
| Markdown | `markdown-it`(打进库,宿主无需安装) | | Markdown | `markdown-it`(打进库,宿主无需安装) |
| peer dep | `vue ^3.4`(宿主提供,库不打包 vue) | | peer dep | `vue ^3.4`(宿主提供,库不打包 vue) |
@@ -31,31 +31,33 @@
## 3. 目录结构与各文件职责 ## 3. 目录结构与各文件职责
实现全部收在**一个自包含文件夹** `src/parallel-agents/`(内部相对路径引用、不依赖构建别名,
整个文件夹可直接拷给宿主用)。`src/` 顶层只剩 demo 与库入口。
``` ```
src/ src/
├─ index.ts 库入口:导出 ParallelAgentsPanel + install 插件 + 类型;import 样式 ├─ index.ts 库入口:从 ./parallel-agents 再导出 + install 插件
├─ main.ts / App.vue dev demo(仅本地调试用,不进库) ├─ main.ts / App.vue dev demo(仅本地调试用,不进库)
├─ config.ts 配置:DEFAULT_BASE_URL / DEFAULT_MODEL / TOKEN_PATH └─ parallel-agents/ ← 自包含组件文件夹(拷走即用)
├─ types.ts 共享类型:Dialogue / SeatView / DialogueStep / ClarificationPayload ├─ index.ts 文件夹入口:import 样式 + 导出组件/类型
│ / AgentInput / AgentResult / SavedTask / StreamEvent(事件协议) ├─ config.ts 配置:DEFAULT_BASE_URL / DEFAULT_MODEL / TOKEN_PATH
├─ api.ts 后端访问层:鉴权(取 token+缓存+401 重登) / SSE 流 / 任务 CRUD / 模型列表 ├─ types.ts 共享类型:Dialogue / SeatView / SeatStatus / DialogueStep /
├─ composables/ │ ClarificationPayload / AgentInput / AgentResult / SavedTask / StreamEvent
│ └─ useParallelRun.ts 运行编排(前端态核心):消费合并 SSE → 维护 dialogues/seats; ├─ api.ts 后端访问层:鉴权(取 token+缓存+401 重登) / SSE 流 / 任务 CRUD / 模型列表
│ start / cancel / hydrate(回显) / reset / rerunAgent(单席位重跑) ├─ useParallelRun.ts 运行编排(前端态核心):消费合并 SSE → 维护 dialogues/seats/busy;
├─ lib/ │ start / cancel / hydrate(回显) / reset / rerunAgent(单席位重跑,可并发协助)
│ ├─ avatar-constants.ts 头像配色 + getAvatarTypeFor(轮转) ├─ avatar-constants.ts 头像配色 + getAvatarTypeFor(轮转)
│ └─ step-display.ts 步骤条:getStepDisplay(图标+中文文案) / getStepArgs(参数子节点) ├─ step-display.ts 步骤条:getStepDisplay(图标+中文文案) / getStepArgs(参数子节点)
├─ styles/ ├─ parallel-agents.css 作用域深色 token(--pa-*) + .pa-* 语义类 + Markdown 样式 + 工具类子集(无 Tailwind)
│ └─ parallel-agents.css 作用域深色 token(--pa-*) + .pa-* 语义类 + Markdown 样式(无 preflight) ├─ ParallelAgentsPanel.vue 主组件:席位条/会话区 + 全部状态编排(表单/按钮在宿主侧)
└─ components/ ├─ ParallelSeatStrip.vue 席位状态条(头像 + running/done/clarify/error 角标,点击切换单显)
├─ ParallelAgentsPanel.vue 主组件:输入区/操作区/模型切换/历史/席位条/会话区 + 全部状态编排
├─ ParallelSeatStrip.vue 席位状态条(头像 + running/done/error 角标,点击切换单显)
├─ MessageBubble.vue 单条气泡:头像/发言人/徽标/时间/步骤条/正文/光标/协作卡片 ├─ MessageBubble.vue 单条气泡:头像/发言人/徽标/时间/步骤条/正文/光标/协作卡片
├─ MessageStepsCard.vue 步骤条(ChainOfThought 结构,1:1 对齐 Step2) ├─ MessageStepsCard.vue 步骤条(ChainOfThought 结构,1:1 对齐 Step2)
├─ StepIcon.vue 步骤图标(lucide 风格 inline SVG,按 IconKey) ├─ StepIcon.vue 步骤图标(lucide 风格 inline SVG,按 IconKey)
├─ ClarificationCard.vue 协作 / 澄清卡片(1:1 对齐圆桌第一步 IntentClarificationCard) ├─ ClarificationCard.vue 协作 / 澄清卡片(1:1 对齐圆桌第一步 IntentClarificationCard)
├─ MarkdownContent.vue markdown-it 渲染 ├─ MarkdownContent.vue markdown-it 渲染
└─ Avatar.vue 机器人 / 真人 SVG 头像 ├─ Avatar.vue 机器人 / 真人 SVG 头像
└─ README.md 文件夹速用说明
``` ```
--- ---
@@ -98,7 +100,7 @@ panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) /
| `agent_start` | `agent_id` | 该席位置 running | | `agent_start` | `agent_id` | 该席位置 running |
| `delta` | `agent_id, text` | 追加正文(节流) | | `delta` | `agent_id, text` | 追加正文(节流) |
| `step` | `agent_id, key(=`tool_calling:<tool>`), label, args` | 步骤条加一步(含完整 args) | | `step` | `agent_id, key(=`tool_calling:<tool>`), label, args` | 步骤条加一步(含完整 args) |
| `agent_clarification` | `agent_id, content, question, clarification_type, clarification_context, options, allow_*` | 设 `dialogue.clarification` → 卡片 | | `agent_clarification`〔已禁用〕 | `agent_id, content, question, clarification_type, clarification_context, options, allow_*` | 设 `dialogue.clarification` → 卡片;席位置 **clarify**(待协助,非 done)。**注**:后端已把 `ask_clarification` 加入 `_EXCLUDED_TOOLS`,本面板**无人值守**不再发该事件;前端协作卡片相关代码保留(兜底,正常不触发) |
| `agent_done` | `agent_id, content` | 收口,正文以终态为准 | | `agent_done` | `agent_id, content` | 收口,正文以终态为准 |
| `agent_error` | `agent_id, message` | 该席位置 error | | `agent_error` | `agent_id, message` | 该席位置 error |
| `all_done` | — | 全部结束 | | `all_done` | — | 全部结束 |
@@ -106,9 +108,10 @@ panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) /
**核心数据模型**(`types.ts`): **核心数据模型**(`types.ts`):
- `Dialogue { id, agentId, sender, avatarType, content, time, confidence, streaming?, steps?, clarification? }` - `Dialogue { id, agentId, sender, avatarType, content, time, confidence, streaming?, steps?, clarification? }`
- `DialogueStep { id, key, label, ts, args? }`(`key` 形如 `tool_calling:web_search`) - `DialogueStep { id, key, label, ts, args? }`(`key` 形如 `tool_calling:web_search`)
- `SeatView { agentId, name, avatarType, status: pending|running|done|error, bubbleId }` - `SeatView { agentId, name, avatarType, status: SeatStatus, bubbleId }`
- `SeatStatus = pending | running | done | clarify | error`(**`clarify` = 待用户协助,非完成**)
- `ClarificationPayload { question, clarificationType, context, options, allowCustom, allowMultiple }` - `ClarificationPayload { question, clarificationType, context, options, allowCustom, allowMultiple }`
- `AgentResult { agent_id, name, content, status }`(宿主读取用) - `AgentResult { agent_id, name, content, status }`(宿主读取用;`status` 同 `SeatStatus`)
> 改协议时:`types.ts` 的 `StreamEvent` 与后端 `parallel_agents.py` 的事件 dict **必须同步**。 > 改协议时:`types.ts` 的 `StreamEvent` 与后端 `parallel_agents.py` 的事件 dict **必须同步**。
@@ -157,23 +160,30 @@ ParallelAgentsPanel
| 模型列表 | `GET /api/models` → `{models:[{name, display_name}]}` | | 模型列表 | `GET /api/models` → `{models:[{name, display_name}]}` |
**后端运行策略(重要)**:每个并行席位是「执行席位」,不是总控。 **后端运行策略(重要)**:每个并行席位是「执行席位」,不是总控。
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash]`:①禁写文件(并行 - **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash, ask_clarification]`:
不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸)。 ①禁写文件(并行不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸);
- **保留** 只读 / 检索工具 + `ask_clarification` → 步骤条、协作卡片正常。 ③禁 `ask_clarification`(**禁用用户协助**:本面板是并行**无人值守**扇出,没有让单席位中途打断、向用户
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)。 要澄清的交互位 —— 席位信息不足时自行做**合理假设**并写进正文交付,而非挂起等人)。
- **保留** 只读 / 检索工具 → 步骤条正常。
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)+ **无人值守护栏**(「信息不足时
自行合理假设并注明,不要反问用户 / 不要挂起等待补充」)。
- 步骤 `args` 通过累积 `tool_call_chunks` + 终态全量 `tool_calls` 补全(流式期间空 args 不发,防钉死)。 - 步骤 `args` 通过累积 `tool_call_chunks` + 终态全量 `tool_calls` 补全(流式期间空 args 不发,防钉死)。
- `ask_clarification` 从终态 messages 抽成结构化 `agent_clarification` 事件。 - `ask_clarification` 已被禁用,正常不再产生 `agent_clarification` 事件;`_clarification_from_messages`
仅作终态兜底(工具被禁后恒返回 `None`),不影响其他业务路由(圆桌 / 普通问答的澄清照常)。
--- ---
## 9. 样式系统(嵌入安全) ## 9. 样式系统(嵌入安全)
`styles/parallel-agents.css`: `parallel-agents.css`(**零依赖、不再用 Tailwind**):
- **不引 Tailwind preflight**(`@import "tailwindcss/theme.css"` + `utilities.css`),避免重置宿主全局样式。
- 颜色全用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》,与圆桌 Step2 深色一致),全部包在 - 颜色全用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》,与圆桌 Step2 深色一致),全部包在
根类 `.parallel-agents` 下,**不污染宿主**。 根类 `.parallel-agents` 下,**不污染宿主**。
- 语义类 `.pa-card / .pa-surface / .pa-border / .pa-text(-muted/-primary) / .pa-input / .pa-btn-* / - 语义类 `.pa-card / .pa-surface / .pa-border / .pa-text(-muted/-primary) / .pa-input / .pa-btn-* /
.pa-badge / .pa-md(Markdown)` —— 组件**上色用这些类**,布局用 Tailwind 工具类。 .pa-badge / .pa-dot-*(席位角标,含 .pa-dot-clarify) / .pa-md(Markdown)` —— 组件**上色用这些类**。
- 布局用文件**末尾的「最小工具类子集」**(`flex` / `gap-*` / `rounded-*` / `text-[11px]` /
`lg:grid-cols-2` 等,模板用到的逐条手写成纯 CSS)。它包在 `@layer pa-utilities` 里,所以无层级的
`.pa-*` 语义类在同属性冲突时**仍压过**工具类(复刻原先 Tailwind-utilities-在-layer 的优先级)。
**维护**:模板新增布局类时,在该子集补一条对应规则即可(或写组件 scoped `<style>`)。
- 面板**自带深色成色**,不随宿主明暗变化;根元素 `h-full w-full flex flex-col min-h-0` 自适应容器。 - 面板**自带深色成色**,不随宿主明暗变化;根元素 `h-full w-full flex flex-col min-h-0` 自适应容器。
--- ---
@@ -181,7 +191,7 @@ ParallelAgentsPanel
## 10. 步骤条 / 协作卡片(与圆桌的对应) ## 10. 步骤条 / 协作卡片(与圆桌的对应)
- **步骤条** `MessageStepsCard.vue` ← 圆桌 `MessageStepsCard.tsx` + `ChainOfThought`:图标列(带竖向连接线) - **步骤条** `MessageStepsCard.vue` ← 圆桌 `MessageStepsCard.tsx` + `ChainOfThought`:图标列(带竖向连接线)
+ 中文文案 + 时间 + 参数子节点。文案/图标映射在 `lib/step-display.ts`(`getStepDisplay`),参数子节点 + 中文文案 + 时间 + 参数子节点。文案/图标映射在 `step-display.ts`(`getStepDisplay`),参数子节点
在 `getStepArgs`(chip / code / text / kv)。加新工具图标:在 `step-display.ts` 的 switch + `StepIcon.vue` 在 `getStepArgs`(chip / code / text / kv)。加新工具图标:在 `step-display.ts` 的 switch + `StepIcon.vue`
各加一条。 各加一条。
- **协作卡片** `ClarificationCard.vue` ← 圆桌第一步 `IntentClarificationCard.tsx`:标题/类型标签/背景/选项 - **协作卡片** `ClarificationCard.vue` ← 圆桌第一步 `IntentClarificationCard.tsx`:标题/类型标签/背景/选项
@@ -208,8 +218,9 @@ pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
| 需求 | 改哪里 | | 需求 | 改哪里 |
| ---------------------------- | --------------------------------------------------------------------------------------- | | ---------------------------- | --------------------------------------------------------------------------------------- |
| 新增一个 SSE 事件类型 | `types.ts` 的 `StreamEvent` + `useParallelRun.handleEvent` + 后端 `parallel_agents.py` | | 新增一个 SSE 事件类型 | `types.ts` 的 `StreamEvent` + `useParallelRun.handleEvent` + 后端 `parallel_agents.py` |
| 步骤条支持新工具的图标/文案 | `lib/step-display.ts`(`getStepDisplay`/`getStepArgs`)+ `StepIcon.vue` | | 步骤条支持新工具的图标/文案 | `step-display.ts`(`getStepDisplay`/`getStepArgs`)+ `StepIcon.vue` |
| 改深色配色 / 加浅色主题 | `styles/parallel-agents.css` 的 `--pa-*`(浅色可在 `.parallel-agents--light` 下覆盖) | | 改深色配色 / 加浅色主题 | `parallel-agents.css` 的 `--pa-*`(浅色可在 `.parallel-agents--light` 下覆盖) |
| 改布局类 / 加新工具类 | `parallel-agents.css` 末尾的「工具类子集」补规则,或组件 scoped `<style>`(不要装 Tailwind) |
| 改默认后端地址 / 模型 | `config.ts`(`DEFAULT_BASE_URL` / `DEFAULT_MODEL`) | | 改默认后端地址 / 模型 | `config.ts`(`DEFAULT_BASE_URL` / `DEFAULT_MODEL`) |
| 多席位并排显示(取消单选) | `ParallelAgentsPanel.vue` 的 `selected` / `visibleSeats` / `toggleSeat` 逻辑 | | 多席位并排显示(取消单选) | `ParallelAgentsPanel.vue` 的 `selected` / `visibleSeats` / `toggleSeat` 逻辑 |
| 宿主自定义存储(不走后端) | 用 `@completed` + `getResults()` 取数据,自行存;不调用 `createTask/updateTask` | | 宿主自定义存储(不走后端) | 用 `@completed` + `getResults()` 取数据,自行存;不调用 `createTask/updateTask` |
@@ -221,6 +232,9 @@ pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
- **agent_id 必须真实**(后端注册过的 id),只填名字不行;可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。 - **agent_id 必须真实**(后端注册过的 id),只填名字不行;可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- 并行席位**禁派活/脚本/写文件**:本面板里每个智能体只做自己那份分析,产出走正文。 - 并行席位**禁派活/脚本/写文件**:本面板里每个智能体只做自己那份分析,产出走正文。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume。 - **已禁用「用户协助」**:并行无人值守,后端把 `ask_clarification` 加入 `_EXCLUDED_TOOLS`,席位不再向用户要
澄清(信息不足时自行合理假设并在正文注明)。前端 `ClarificationCard.vue` / `onClarify` / `rerunAgent`
等协作链路代码保留作兜底,正常不触发;**仅影响本面板**,圆桌 / 普通问答的澄清不受影响。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume(当前已禁用,仅兜底保留)。
- token 取自公共端点(无门槛旁路)—— 适合内网;若网关要暴露公网,建议给取 token 端点加共享密钥校验。 - token 取自公共端点(无门槛旁路)—— 适合内网;若网关要暴露公网,建议给取 token 端点加共享密钥校验。
- 跨域:宿主与后端不同源时,后端需放开该来源 CORS + `credentials`。 - 跨域:宿主与后端不同源时,后端需放开该来源 CORS + `credentials`。
-2
View File
@@ -29,10 +29,8 @@
"markdown-it": "^14.1.0" "markdown-it": "^14.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vue": "^3.5.0", "vue": "^3.5.0",
+84 -15
View File
@@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */ /**
* Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。
*
* 同时作为「新契约」的参考实现:任务名 / 简介 / 模型由宿主持有并通过 props 传入,
* 「重新分析 / 保存 / 停止」等按钮由宿主渲染,通过模板 ref 调用面板 expose 出的方法。
*/
import { reactive, ref } from "vue"; import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue"; import { ParallelAgentsPanel } from "./parallel-agents";
import type { AgentInput, AgentResult } from "./types"; import type { AgentInput, AgentResult } from "./parallel-agents";
import { DEFAULT_BASE_URL } from "./config"; import { DEFAULT_BASE_URL } from "./parallel-agents/config";
const cfg = reactive({ const cfg = reactive({
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL, baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
@@ -16,13 +21,24 @@ const cfg = reactive({
const open = ref(false); const open = ref(false);
const agents = ref<AgentInput[]>([]); 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 回显」。 // 面板保存 / 生成 id 时回填,方便复制下来下次填进去测「按 taskId 回显」。
function onTaskId(id: string) { function onTaskId(id: string) {
cfg.taskId = id; cfg.taskId = id;
localStorage.setItem("pa.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[]) { function onCompleted(results: AgentResult[]) {
console.log("[demo] completed → 各智能体返回内容:", results); console.log("[demo] completed → 全部分析完,各智能体返回内容:", results);
} }
function parseAgents(): AgentInput[] { function parseAgents(): AgentInput[] {
@@ -68,21 +84,74 @@ function launch() {
</button> </button>
</div> </div>
<!-- 模拟宿主弹窗:面板自适应容器 --> <!-- 模拟宿主弹窗:宿主自己渲染「任务名 / 简介 / 模型 + 按钮」,面板只展示会话 -->
<div <div
v-if="open" 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" 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" @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)"> <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">
<ParallelAgentsPanel <!-- 宿主工具条:这部分原来在面板内,现在由宿主自己掌控 -->
:base-url="cfg.baseUrl" <div style="display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border-bottom: 1px solid #1e2d4d">
:username="cfg.username || undefined" <input
:task-id="cfg.taskId || undefined" v-model="taskName"
:agents="agents" placeholder="任务名称"
@update:taskId="onTaskId" style="padding: 6px 10px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px"
@completed="onCompleted" />
/> <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> </div>
</div> </div>
+13 -3
View File
@@ -3,13 +3,23 @@
* import { ParallelAgentsPanel } from "parallel-agents-vue"; * import { ParallelAgentsPanel } from "parallel-agents-vue";
* import "parallel-agents-vue/style.css"; * import "parallel-agents-vue/style.css";
* 或全局注册:app.use(ParallelAgentsVue)。 * 或全局注册:app.use(ParallelAgentsVue)。
*
* 实现全部收在自包含的 `./parallel-agents/` 文件夹里(含样式,零 Tailwind 依赖)。需要直接拷
* 源码的用户,整个 parallel-agents/ 文件夹拷走即用,见该目录下 README.md。
*/ */
import "./styles/parallel-agents.css";
import type { App } from "vue"; import type { App } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue"; import { ParallelAgentsPanel } from "./parallel-agents";
export { ParallelAgentsPanel }; export { ParallelAgentsPanel };
export type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView, StreamEvent } from "./types"; export type {
AgentInput,
AgentResult,
Dialogue,
SavedTask,
SeatStatus,
SeatView,
StreamEvent,
} from "./parallel-agents";
export default { export default {
install(app: App) { install(app: App) {
-1
View File
@@ -1,5 +1,4 @@
import { createApp } from "vue"; import { createApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
import "./styles/parallel-agents.css";
createApp(App).mount("#app"); createApp(App).mount("#app");
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import { avatarColor, isHuman } from "@/lib/avatar-constants"; import { avatarColor, isHuman } from "./avatar-constants";
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 }); const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
const color = computed(() => avatarColor(props.type)); const color = computed(() => avatarColor(props.type));
@@ -5,7 +5,7 @@
* 自定义输入 + 发送按钮。提交时回传格式化好的回答文本。 * 自定义输入 + 发送按钮。提交时回传格式化好的回答文本。
*/ */
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import type { ClarificationPayload, ClarOption } from "@/types"; import type { ClarificationPayload, ClarOption } from "./types";
const props = defineProps<{ payload: ClarificationPayload; submitting?: boolean }>(); const props = defineProps<{ payload: ClarificationPayload; submitting?: boolean }>();
const emit = defineEmits<{ (e: "submit", answer: string): void }>(); const emit = defineEmits<{ (e: "submit", answer: string): void }>();
@@ -2,7 +2,7 @@
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 / /** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */ * 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
import { computed } from "vue"; import { computed } from "vue";
import type { Dialogue } from "@/types"; import type { Dialogue } from "./types";
import Avatar from "./Avatar.vue"; import Avatar from "./Avatar.vue";
import MessageStepsCard from "./MessageStepsCard.vue"; import MessageStepsCard from "./MessageStepsCard.vue";
import MarkdownContent from "./MarkdownContent.vue"; import MarkdownContent from "./MarkdownContent.vue";
@@ -4,8 +4,8 @@
* slate 边框卡 + 每步「图标列(带竖向连接线) + 中文文案 + 时间 + 参数子节点」。 * slate 边框卡 + 每步「图标列(带竖向连接线) + 中文文案 + 时间 + 参数子节点」。
*/ */
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import type { DialogueStep } from "@/types"; import type { DialogueStep } from "./types";
import { getStepArgs, getStepDisplay } from "@/lib/step-display"; import { getStepArgs, getStepDisplay } from "./step-display";
import StepIcon from "./StepIcon.vue"; import StepIcon from "./StepIcon.vue";
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>(); const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
@@ -1,26 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 并行多智能体面板(主组件,可嵌入)。 * 并行多智能体面板(主组件,可嵌入)—— 纯展示 + 暴露方法版本。
* *
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表 * 「任务名称 / 简介 / 模型」全部由宿主通过 props 传入;本组件不再渲染顶部表单与按钮,
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存 * 只展示「席位状态条 + 并行会话」。宿主自行渲染「重新分析 / 保存」等按钮,并通过模板 ref
* 聊天记录 + 回显 + 重新分析。 * 调用本组件暴露的 `reanalyze()` / `save()` / `cancel()` 等方法。
*
* 并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表照搬圆桌 Step2 并行多席位的
* 深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存聊天记录 + 回显 + 重新分析。
* *
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。 * 自适应容器:根元素 `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 type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "./types";
import { useParallelRun } from "@/composables/useParallelRun"; import { useParallelRun } from "./useParallelRun";
import { import {
createTask, createTask,
ensureTask, ensureTask,
listModels,
listTasks, listTasks,
updateTask, updateTask,
type ApiConfig, type ApiConfig,
type ModelItem, } from "./api";
} from "@/api"; import { DEFAULT_BASE_URL } from "./config";
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue"; import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue"; import MessageBubble from "./MessageBubble.vue";
@@ -37,15 +38,26 @@ const props = withDefaults(
loginPassword?: string; loginPassword?: string;
agents: AgentInput[]; agents: AgentInput[];
taskId?: string; taskId?: string;
/** 任务名称:由宿主传入(可配合 update:taskName 做 v-model)。 */
taskName?: string;
/** 任务简介:由宿主传入(可配合 update:brief 做 v-model)。 */
brief?: string;
/** 本次并行分析使用的模型;空 = 后端回退默认。 */
model?: string; model?: string;
dark?: boolean; dark?: boolean;
}>(), }>(),
{ dark: true }, { dark: true, taskName: "", brief: "", model: "" },
); );
const emit = defineEmits<{ const emit = defineEmits<{
(e: "saved", task: SavedTask): void; (e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): 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; (e: "completed", results: AgentResult[]): void;
}>(); }>();
@@ -58,21 +70,26 @@ const cfg = computed<ApiConfig>(() => ({
loginPassword: props.loginPassword, loginPassword: props.loginPassword,
})); }));
const run = useParallelRun(); // 单个智能体完成即回传它的结果(一分析完就给宿主)。
const { dialogues, seats, running } = run; const run = useParallelRun({
onAgentSettled: (agentId) => {
const r = getResult(agentId);
if (r) emit("agent-completed", r);
},
});
const { dialogues, seats, running, busy } = run;
// 任务名 / 简介 / 模型全部由宿主 props 提供(本组件不再持有可编辑表单)。
const taskName = computed(() => props.taskName ?? "");
const brief = computed(() => props.brief ?? "");
const model = computed(() => (props.model || "").trim() || undefined);
const form = reactive({ taskName: "", brief: "" });
const currentTaskId = ref<string | null>(props.taskId ?? null); const currentTaskId = ref<string | null>(props.taskId ?? null);
const selected = ref<string[]>([]); const selected = ref<string[]>([]);
const saving = ref(false); const saving = ref(false);
const history = ref<SavedTask[]>([]); const history = ref<SavedTask[]>([]);
const showHistory = ref(false);
const errorMsg = ref<string | null>(null); const errorMsg = ref<string | null>(null);
// 模型切换:拉后端模型列表,空字符串 = 用默认(prop/config/后端回退)。
const models = ref<ModelItem[]>([]);
const selectedModel = ref<string>(props.model ?? DEFAULT_MODEL ?? "");
const dialogueById = computed(() => { const dialogueById = computed(() => {
const m = new Map<number, Dialogue>(); const m = new Map<number, Dialogue>();
for (const d of dialogues.value) m.set(d.id, d); 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 hasResults = computed(() => dialogues.value.length > 0);
const canStart = computed( 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 { function getResult(agentId: string): AgentResult | null {
return getResults().find((r) => r.agent_id === agentId) ?? null; return getResults().find((r) => r.agent_id === agentId) ?? null;
} }
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />` → panelRef.value.getResults() // 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />`
defineExpose({ getResults, getResult, start: () => onStart(), cancel: () => run.cancel() }); // 宿主自行渲染按钮,调用下面这些方法 / 读取状态:
// 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) { async function onClarify(agentId: string, answer: string) {
errorMsg.value = null; errorMsg.value = null;
const brief = `${form.brief}\n\n【用户补充】${answer}`.trim(); const merged = `${brief.value}\n\n【用户补充】${answer}`.trim();
try { try {
await run.rerunAgent(cfg.value, { await run.rerunAgent(cfg.value, {
taskName: form.taskName, taskName: taskName.value,
brief, brief: merged,
agentId, agentId,
model: selectedModel.value || undefined, model: model.value,
}); });
emit("completed", getResults()); emit("completed", getResults());
void autoSave(); // 重跑完也自动存一次
} catch (err) { } catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err); errorMsg.value = err instanceof Error ? err.message : String(err);
} }
@@ -162,26 +201,37 @@ async function onStart() {
errorMsg.value = null; errorMsg.value = null;
try { try {
await run.start(cfg.value, { await run.start(cfg.value, {
taskName: form.taskName, taskName: taskName.value,
brief: form.brief, brief: brief.value,
agents: props.agents, agents: props.agents,
model: selectedModel.value || undefined, model: model.value,
}); });
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。 // 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults()); emit("completed", getResults());
void autoSave(); // 分析完自动保存一次
} catch (err) { } catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(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() { async function onSave() {
if (saving.value || !hasResults.value) return; if (saving.value || !hasResults.value) return;
saving.value = true; saving.value = true;
errorMsg.value = null; errorMsg.value = null;
try { try {
const title = form.taskName.trim() || "未命名任务"; const title = taskName.value.trim() || "未命名任务";
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a })); 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 则新建。 // 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。
let saved: SavedTask | null; let saved: SavedTask | null;
const id = currentTaskId.value; const id = currentTaskId.value;
@@ -206,14 +256,17 @@ async function onSave() {
async function refreshHistory() { async function refreshHistory() {
try { 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 { } catch {
/* 历史拉取失败不阻断主流程 */ /* 历史拉取失败不阻断主流程 */
} }
} }
async function loadTask(id: string) { async function loadTask(id: string) {
showHistory.value = false;
errorMsg.value = null; errorMsg.value = null;
try { try {
// get-or-create:新 id 直接初始化空记录(不 404);已存在则回显其聊天记录。 // get-or-create:新 id 直接初始化空记录(不 404);已存在则回显其聊天记录。
@@ -224,11 +277,12 @@ async function loadTask(id: string) {
currentTaskId.value = task.id; currentTaskId.value = task.id;
selected.value = []; selected.value = [];
if (task.messages && task.messages.length) { if (task.messages && task.messages.length) {
form.taskName = task.title ?? ""; // 标题 / 简介由宿主持有:回传让宿主同步自己的输入框(v-model)。
form.brief = task.brief ?? ""; emit("update:taskName", task.title ?? "");
emit("update:brief", task.brief ?? "");
run.hydrate(task.messages); run.hydrate(task.messages);
} else { } else {
// 空记录(新初始化):清空当前会话,等用户填任务 / 开始分析。 // 空记录(新初始化):清空当前会话,等宿主填任务 / 触发分析。
run.reset(); run.reset();
} }
emit("update:taskId", task.id); emit("update:taskId", task.id);
@@ -237,19 +291,14 @@ async function loadTask(id: string) {
} }
} }
async function refreshModels() { onMounted(async () => {
const list = await listModels(cfg.value); await refreshHistory();
models.value = list; if (props.taskId) {
// 当前选择不在列表里(且非空)时,回退为默认(空 = 后端回退)。 void loadTask(props.taskId);
if (selectedModel.value && !list.some((m) => m.name === selectedModel.value)) { } else if (history.value.length) {
selectedModel.value = ""; // 没指定 taskId:默认回显最新一条聊天记录。
void loadTask(history.value[0].id);
} }
}
onMounted(() => {
void refreshHistory();
void refreshModels();
if (props.taskId) void loadTask(props.taskId);
}); });
</script> </script>
@@ -258,98 +307,17 @@ onMounted(() => {
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col" class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
:class="dark ? 'parallel-agents--dark' : ''" :class="dark ? 'parallel-agents--dark' : ''"
> >
<!-- 顶部:任务名 + 操作 --> <!-- 顶部表单与按钮已移除:任务名 / 简介 / 模型由宿主 props 传入,
<div class="pa-border flex shrink-0 flex-col gap-2 border-b px-4 py-3"> 「重新分析 / 保存 / 停止」由宿主调用本组件 expose 出的方法。 -->
<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"> <p
模型 v-if="errorMsg"
<select class="pa-border shrink-0 border-b px-4 py-2 text-xs"
v-model="selectedModel" style="color: var(--pa-rose)"
:disabled="running" >
class="pa-input px-2 py-1 text-xs" {{ errorMsg }}
title="选择本次并行分析使用的模型" </p>
>
<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"> <div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
@@ -371,7 +339,7 @@ onMounted(() => {
<MessageBubble <MessageBubble
v-if="dialogueById.get(seat.bubbleId)" v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!" :msg="dialogueById.get(seat.bubbleId)!"
:submitting="running" :submitting="running || busy.has(seat.agentId)"
@clarify="onClarify" @clarify="onClarify"
/> />
<div v-else class="py-6 text-center text-xs pa-text-muted"> <div v-else class="py-6 text-center text-xs pa-text-muted">
@@ -383,7 +351,7 @@ onMounted(() => {
v-else v-else
class="flex h-full flex-col items-center justify-center gap-2 text-center" 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> <p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
</div> </div>
</div> </div>
@@ -2,13 +2,14 @@
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标, /** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
* 点击可切换主区显示哪两列。 */ * 点击可切换主区显示哪两列。 */
import { computed } from "vue"; import { computed } from "vue";
import type { SeatView } from "@/types"; import type { SeatView } from "./types";
import Avatar from "./Avatar.vue"; import Avatar from "./Avatar.vue";
const props = defineProps<{ seats: SeatView[]; selected: string[] }>(); const props = defineProps<{ seats: SeatView[]; selected: string[] }>();
const emit = defineEmits<{ (e: "toggle", agentId: string): void }>(); const emit = defineEmits<{ (e: "toggle", agentId: string): void }>();
const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length); const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length);
const clarifyCount = computed(() => props.seats.filter((s) => s.status === "clarify").length);
</script> </script>
<template> <template>
@@ -16,6 +17,7 @@ const runningCount = computed(() => props.seats.filter((s) => s.status === "runn
<span class="shrink-0 text-[11px] font-bold pa-text-muted"> <span class="shrink-0 text-[11px] font-bold pa-text-muted">
并行 {{ seats.length }} 席 并行 {{ seats.length }} 席
<span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span> <span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span>
<span v-if="clarifyCount > 0" style="color: var(--pa-amber)">({{ clarifyCount }} 待协助)</span>
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span> <span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
</span> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -46,12 +48,14 @@ const runningCount = computed(() => props.seats.filter((s) => s.status === "runn
:class="{ :class="{
'pa-dot-running': seat.status === 'running', 'pa-dot-running': seat.status === 'running',
'pa-dot-done': seat.status === 'done', 'pa-dot-done': seat.status === 'done',
'pa-dot-clarify': seat.status === 'clarify',
'pa-dot-error': seat.status === 'error', 'pa-dot-error': seat.status === 'error',
}" }"
:style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''" :style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''"
> >
<span v-if="seat.status === 'running'" class="pa-spin">◜</span> <span v-if="seat.status === 'running'" class="pa-spin">◜</span>
<span v-else-if="seat.status === 'done'">✓</span> <span v-else-if="seat.status === 'done'">✓</span>
<span v-else-if="seat.status === 'clarify'">?</span>
<span v-else-if="seat.status === 'error'">✕</span> <span v-else-if="seat.status === 'error'">✕</span>
</span> </span>
</button> </button>
@@ -0,0 +1,61 @@
# 并行多智能体面板(parallel-agents)
一个**自包含**的 Vue 3 可嵌入组件:输入任务名 + 简介,并行调用多个智能体,深色消息列表 +
步骤条 + 协作卡片 + 保存回显 + 重新分析。
## 怎么用(复制即用)
把**整个 `parallel-agents/` 文件夹**拷到你项目的 `src/` 下,然后:
```ts
import { ParallelAgentsPanel } from "@/parallel-agents";
// 或: import { ParallelAgentsPanel } from "./parallel-agents";
```
```vue
<ParallelAgentsPanel
ref="panel"
:base-url="'http://localhost:8001'"
:username="'demo'"
:task-id="taskId || undefined"
v-model:task-name="taskName"
v-model:brief="brief"
:model="model"
:agents="agents"
@agent-completed="onAgentCompleted"
@completed="onCompleted"
/>
```
样式由入口 `index.ts` 自动 `import "./parallel-agents.css"`,**无需** Tailwind / 任何 CSS 框架。
若你不走 `index.ts` 而是直接 import 组件,请自行引一次该 CSS。
## 依赖
- `vue` ^3.4
- `markdown-it` ^14(正文 Markdown 渲染)—— `pnpm add markdown-it`
> 不再依赖 Tailwind:布局类已内置为 `parallel-agents.css` 末尾的「最小工具类子集」,全部作用域
> 在根类 `.parallel-agents` 下,绝不外泄污染宿主页面。
## 文件结构(都在本文件夹内,可随意改源码)
| 文件 | 作用 |
|------|------|
| `ParallelAgentsPanel.vue` | 主组件(席位条 + 并行会话,暴露 start/save/cancel 等方法) |
| `ParallelSeatStrip.vue` | 席位状态条(running / done / **clarify 待协助** / error) |
| `MessageBubble.vue` | 单条消息气泡(头像 / 步骤条 / 正文 / 协作卡片) |
| `MessageStepsCard.vue` · `StepIcon.vue` | 执行步骤条 / 思维链卡 + 图标 |
| `ClarificationCard.vue` | 协作 / 澄清卡片(用户协助) |
| `MarkdownContent.vue` | Markdown 正文渲染(markdown-it) |
| `Avatar.vue` | 头像(机器人 / 真人 SVG) |
| `useParallelRun.ts` | 并行运行编排(消费合并 SSE、按席位路由、待协助/并发协助) |
| `api.ts` | 后端访问层(鉴权 + 扇出 SSE + 任务 CRUD) |
| `types.ts` · `config.ts` · `avatar-constants.ts` · `step-display.ts` | 类型 / 配置 / 头像配色 / 步骤文案 |
| `parallel-agents.css` | 作用域样式(深蓝深色 token + 语义类 + 工具类子集) |
## 关键状态
席位/结果状态 `SeatStatus`:`pending | running | done | clarify | error`。其中 **`clarify`** 表示
该智能体在等用户协助(待确认),**不是已完成**——宿主据此判断「未完成」,多个智能体同时待协助
时可分别协助、互不阻塞。
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IconKey } from "@/lib/step-display"; import type { IconKey } from "./step-display";
defineProps<{ name: IconKey; size?: number }>(); defineProps<{ name: IconKey; size?: number }>();
</script> </script>
@@ -0,0 +1,27 @@
/**
* 并行多智能体面板 —— 自包含文件夹入口。
*
* 使用方式(把整个 parallel-agents/ 文件夹拷进你的项目 src 下即可):
*
* import { ParallelAgentsPanel } from "@/parallel-agents";
* // 或相对路径: import { ParallelAgentsPanel } from "./parallel-agents";
*
* 样式会被这里自动引入(零依赖、作用域在 .parallel-agents 下,不污染宿主,无需 Tailwind)。
* 仅依赖 vue 与 markdown-it(正文 Markdown 渲染)。详见同目录 README.md。
*/
import "./parallel-agents.css";
export { default as ParallelAgentsPanel } from "./ParallelAgentsPanel.vue";
export type {
AgentInput,
AgentResult,
ClarificationPayload,
ClarOption,
Dialogue,
DialogueStep,
SavedTask,
SeatStatus,
SeatView,
StreamEvent,
} from "./types";
export type { ApiConfig } from "./api";
@@ -0,0 +1,456 @@
/* 并行多智能体面板样式 —— 零依赖、不污染宿主。
*
* 不再依赖 Tailwind:本文件自带一份「最小布局工具类子集」(文件末尾),全部作用域在根类
* `.parallel-agents` 下,绝不外泄到宿主页面。其它项目把整个 parallel-agents/ 文件夹拷走、
* 只 `import "./parallel-agents.css"` 即可,无需安装或配置 Tailwind。
*
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
* 深色一致)。语义颜色用 `.pa-*` 类,布局用末尾的工具类子集。 */
/* ── 作用域 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));
}
/* ── 语义颜色类(组件用这些上色,布局用文件末尾的工具类子集)──────────────────── */
.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;
}
.parallel-agents .pa-dot-clarify {
background-color: var(--pa-amber);
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);
}
/* ════════════════════════════════════════════════════════════════════════════
* 最小布局工具类子集(替代 Tailwind)
* 只实现本组件模板用到的类,全部作用域在 .parallel-agents 下,不影响宿主。
* 维护提示:模板里新增布局类时,在此处补一条对应规则即可(或直接写 scoped <style>)。
*
* 放进 @layer:让无层级的 `.pa-*` 语义类(颜色/按钮/徽标)在同名属性冲突时仍**压过**这里的
* 工具类,复刻原先 Tailwind utilities 在 @layer、pa-* 无层级时的优先级(如 pa-btn-primary
* 的 8px 圆角压过 rounded-xl)。
* ════════════════════════════════════════════════════════════════════════════ */
@layer pa-utilities {
/* display */
.parallel-agents .flex { display: flex; }
.parallel-agents .inline-flex { display: inline-flex; }
.parallel-agents .inline-block { display: inline-block; }
.parallel-agents .block { display: block; }
.parallel-agents .grid { display: grid; }
/* flex */
.parallel-agents .flex-col { flex-direction: column; }
.parallel-agents .flex-1 { flex: 1 1 0%; }
.parallel-agents .flex-wrap { flex-wrap: wrap; }
.parallel-agents .items-center { align-items: center; }
.parallel-agents .items-start { align-items: flex-start; }
.parallel-agents .justify-center { justify-content: center; }
.parallel-agents .justify-between { justify-content: space-between; }
.parallel-agents .justify-end { justify-content: flex-end; }
.parallel-agents .shrink-0 { flex-shrink: 0; }
/* grid */
.parallel-agents .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
@media (min-width: 1024px) {
.parallel-agents .lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* position */
.parallel-agents .relative { position: relative; }
.parallel-agents .absolute { position: absolute; }
.parallel-agents .inset-0 { inset: 0; }
.parallel-agents .left-1\/2 { left: 50%; }
.parallel-agents .-bottom-0\.5 { bottom: -0.125rem; }
.parallel-agents .-right-0\.5 { right: -0.125rem; }
/* sizing */
.parallel-agents .w-full { width: 100%; }
.parallel-agents .w-px { width: 1px; }
.parallel-agents .h-full { height: 100%; }
.parallel-agents .h-8 { height: 2rem; }
.parallel-agents .min-w-0 { min-width: 0; }
.parallel-agents .min-h-0 { min-height: 0; }
.parallel-agents .min-h-16 { min-height: 4rem; }
.parallel-agents .max-w-full { max-width: 100%; }
.parallel-agents .max-h-60 { max-height: 15rem; }
.parallel-agents .size-1 { width: 0.25rem; height: 0.25rem; }
.parallel-agents .size-3\.5 { width: 0.875rem; height: 0.875rem; }
/* gap */
.parallel-agents .gap-0\.5 { gap: 0.125rem; }
.parallel-agents .gap-1 { gap: 0.25rem; }
.parallel-agents .gap-1\.5 { gap: 0.375rem; }
.parallel-agents .gap-2 { gap: 0.5rem; }
.parallel-agents .gap-3 { gap: 0.75rem; }
.parallel-agents .gap-4 { gap: 1rem; }
/* space-y(子元素纵向间距) */
.parallel-agents .space-y-1 > * + * { margin-top: 0.25rem; }
.parallel-agents .space-y-1\.5 > * + * { margin-top: 0.375rem; }
.parallel-agents .space-y-2 > * + * { margin-top: 0.5rem; }
/* padding */
.parallel-agents .p-0\.5 { padding: 0.125rem; }
.parallel-agents .p-2 { padding: 0.5rem; }
.parallel-agents .p-3 { padding: 0.75rem; }
.parallel-agents .p-4 { padding: 1rem; }
.parallel-agents .px-1\.5 { padding-left: 0.375rem; padding-right: 0.375rem; }
.parallel-agents .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.parallel-agents .px-2\.5 { padding-left: 0.625rem; padding-right: 0.625rem; }
.parallel-agents .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.parallel-agents .px-4 { padding-left: 1rem; padding-right: 1rem; }
.parallel-agents .py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
.parallel-agents .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.parallel-agents .py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
.parallel-agents .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.parallel-agents .py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.parallel-agents .pt-0\.5 { padding-top: 0.125rem; }
.parallel-agents .pb-2 { padding-bottom: 0.5rem; }
/* margin */
.parallel-agents .mt-0\.5 { margin-top: 0.125rem; }
.parallel-agents .mt-2 { margin-top: 0.5rem; }
.parallel-agents .mt-3 { margin-top: 0.75rem; }
.parallel-agents .mb-2 { margin-bottom: 0.5rem; }
.parallel-agents .ml-2 { margin-left: 0.5rem; }
.parallel-agents .-mx-px { margin-left: -1px; margin-right: -1px; }
/* border + radius */
.parallel-agents .border { border-width: 1px; border-style: solid; }
.parallel-agents .border-b { border-bottom-width: 1px; border-bottom-style: solid; }
.parallel-agents .rounded { border-radius: 0.25rem; }
.parallel-agents .rounded-md { border-radius: 0.375rem; }
.parallel-agents .rounded-lg { border-radius: 0.5rem; }
.parallel-agents .rounded-xl { border-radius: 0.75rem; }
.parallel-agents .rounded-full { border-radius: 9999px; }
/* font-size(含任意值) */
.parallel-agents .text-\[8px\] { font-size: 8px; }
.parallel-agents .text-\[10px\] { font-size: 10px; }
.parallel-agents .text-\[11px\] { font-size: 11px; }
.parallel-agents .text-xs { font-size: 0.75rem; line-height: 1rem; }
.parallel-agents .text-sm { font-size: 0.875rem; line-height: 1.25rem; }
/* font-weight */
.parallel-agents .font-normal { font-weight: 400; }
.parallel-agents .font-medium { font-weight: 500; }
.parallel-agents .font-semibold { font-weight: 600; }
.parallel-agents .font-bold { font-weight: 700; }
.parallel-agents .font-extrabold { font-weight: 800; }
.parallel-agents .font-black { font-weight: 900; }
/* font 其它 */
.parallel-agents .font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.parallel-agents .italic { font-style: italic; }
.parallel-agents .uppercase { text-transform: uppercase; }
.parallel-agents .tracking-wide { letter-spacing: 0.025em; }
/* line-height */
.parallel-agents .leading-snug { line-height: 1.375; }
.parallel-agents .leading-relaxed { line-height: 1.625; }
/* text-align */
.parallel-agents .text-left { text-align: left; }
.parallel-agents .text-center { text-align: center; }
/* 文本截断 / 换行 */
.parallel-agents .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.parallel-agents .break-words { overflow-wrap: break-word; }
.parallel-agents .line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
/* overflow */
.parallel-agents .overflow-hidden { overflow: hidden; }
.parallel-agents .overflow-auto { overflow: auto; }
.parallel-agents .overflow-x-auto { overflow-x: auto; }
.parallel-agents .overflow-y-auto { overflow-y: auto; }
/* 效果 / 变换 / 过渡 */
.parallel-agents .opacity-60 { opacity: 0.6; }
.parallel-agents .shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
.parallel-agents .resize-none { resize: none; }
.parallel-agents .rotate-90 { transform: rotate(90deg); }
.parallel-agents .transition {
transition-property: color, background-color, border-color, fill, stroke, opacity,
box-shadow, transform, filter, backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.parallel-agents .transition-transform {
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.parallel-agents .hover\:opacity-90:hover { opacity: 0.9; }
} /* @layer pa-utilities */
@@ -44,12 +44,15 @@ export interface Dialogue {
clarification?: ClarificationPayload; clarification?: ClarificationPayload;
} }
/** 席位 / 结果的状态。`clarify` = 智能体在等用户协助(待确认),不是已完成。 */
export type SeatStatus = "pending" | "running" | "done" | "clarify" | "error";
/** 并行席位的视图状态(席位状态条用)。 */ /** 并行席位的视图状态(席位状态条用)。 */
export interface SeatView { export interface SeatView {
agentId: string; agentId: string;
name: string; name: string;
avatarType: string; avatarType: string;
status: "pending" | "running" | "done" | "error"; status: SeatStatus;
/** 关联的 Dialogue.id。 */ /** 关联的 Dialogue.id。 */
bubbleId: number; bubbleId: number;
} }
@@ -87,7 +90,8 @@ export interface AgentResult {
name: string; name: string;
/** 该智能体模型返回的正文(Markdown)。 */ /** 该智能体模型返回的正文(Markdown)。 */
content: string; content: string;
status: "pending" | "running" | "done" | "error"; /** `clarify` 表示在等用户协助(待确认),宿主据此判断「未完成」。 */
status: SeatStatus;
} }
/** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */ /** 持久化的任务记录(与后端 /api/parallel-agent-tasks 对应)。 */
@@ -3,9 +3,9 @@
* 气泡,维护并行席位状态条;带轻量节流(rAF 合并增量),支持取消、回显 hydrate、重置。 * 气泡,维护并行席位状态条;带轻量节流(rAF 合并增量),支持取消、回显 hydrate、重置。
*/ */
import { ref, shallowRef } from "vue"; import { ref, shallowRef } from "vue";
import type { AgentInput, Dialogue, SeatView, StreamEvent } from "@/types"; import type { AgentInput, Dialogue, SeatStatus, SeatView, StreamEvent } from "./types";
import { getAvatarTypeFor } from "@/lib/avatar-constants"; import { getAvatarTypeFor } from "./avatar-constants";
import { streamParallelAgents, type ApiConfig } from "@/api"; import { streamParallelAgents, type ApiConfig } from "./api";
function nowTime(): string { function nowTime(): string {
const d = new Date(); const d = new Date();
@@ -18,12 +18,37 @@ function nextId(): number {
return _seq++; 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 dialogues = ref<Dialogue[]>([]);
const seats = ref<SeatView[]>([]); const seats = ref<SeatView[]>([]);
const running = ref(false); const running = ref(false);
const abortRef = shallowRef<AbortController | null>(null); const abortRef = shallowRef<AbortController | null>(null);
// 正在「单席位重跑」(用户协助后继续)的 agentId 集合。澄清卡片的禁用态按席位取这里,
// 而不是全局 running —— 这样多个智能体都待协助时,协助 A 不会禁用 B/C 的卡片。
const busy = ref<Set<string>>(new Set());
// 每个重跑各自的 AbortController(支持多席位并发协助 + 统一取消)。
const acByAgent = new Map<string, AbortController>();
function setBusy(agentId: string, on: boolean) {
const next = new Set(busy.value);
if (on) next.add(agentId);
else next.delete(agentId);
busy.value = next;
}
// 本轮已「结算」(回调过 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。 // 增量节流:累积到 pending,rAF 合并刷新一次,避免逐 token reflow。
const pending = new Map<string, string>(); // agentId -> 待追加文本 const pending = new Map<string, string>(); // agentId -> 待追加文本
let rafScheduled = false; let rafScheduled = false;
@@ -48,6 +73,11 @@ export function useParallelRun() {
requestAnimationFrame(flushPending); requestAnimationFrame(flushPending);
} }
// 断流兜底时把席位状态归一为「终态」:保留待协助 / 出错,其余(running/pending)算完成。
function settleStatusOf(status: SeatStatus): SeatStatus {
return status === "clarify" || status === "error" ? status : "done";
}
function setSeatStatus(agentId: string, status: SeatView["status"]) { function setSeatStatus(agentId: string, status: SeatView["status"]) {
const s = seats.value.find((x) => x.agentId === agentId); const s = seats.value.find((x) => x.agentId === agentId);
if (s) s.status = status; if (s) s.status = status;
@@ -93,6 +123,7 @@ export function useParallelRun() {
} }
setSeatStatus(ev.agent_id, "done"); setSeatStatus(ev.agent_id, "done");
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "done");
break; break;
} }
case "agent_clarification": { case "agent_clarification": {
@@ -111,8 +142,10 @@ export function useParallelRun() {
d.streaming = false; d.streaming = false;
d.confidence = "待确认"; d.confidence = "待确认";
} }
setSeatStatus(ev.agent_id, "done"); // 待协助是独立状态,不是 done —— 否则席位条显示 ✓、宿主判定已完成。
setSeatStatus(ev.agent_id, "clarify");
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "clarify");
break; break;
} }
case "agent_error": { case "agent_error": {
@@ -125,6 +158,7 @@ export function useParallelRun() {
} }
setSeatStatus(ev.agent_id, "error"); setSeatStatus(ev.agent_id, "error");
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
settleAgent(ev.agent_id, "error");
break; break;
} }
case "all_done": case "all_done":
@@ -188,6 +222,8 @@ export function useParallelRun() {
} }
} }
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态,保留待协助/出错)。
for (const s of seats.value) settleAgent(s.agentId, settleStatusOf(s.status));
running.value = false; running.value = false;
abortRef.value = null; abortRef.value = null;
} }
@@ -195,6 +231,9 @@ export function useParallelRun() {
function cancel() { function cancel() {
abortRef.value?.abort(); abortRef.value?.abort();
for (const ac of acByAgent.values()) ac.abort();
acByAgent.clear();
busy.value = new Set();
running.value = false; running.value = false;
} }
@@ -206,7 +245,8 @@ export function useParallelRun() {
cfg: ApiConfig, cfg: ApiConfig,
args: { taskName: string; brief: string; agentId: string; model?: string }, args: { taskName: string; brief: string; agentId: string; model?: string },
): Promise<void> { ): Promise<void> {
if (running.value) return; // 整批分析进行中不允许单独重跑(会和合并流抢同一气泡);同一席位也不能并发重跑。
if (running.value || busy.value.has(args.agentId)) return;
const d = dialogues.value.find((x) => x.agentId === args.agentId); const d = dialogues.value.find((x) => x.agentId === args.agentId);
if (!d) return; if (!d) return;
d.content = ""; d.content = "";
@@ -218,9 +258,11 @@ export function useParallelRun() {
setSeatStatus(args.agentId, "running"); setSeatStatus(args.agentId, "running");
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
running.value = true; settled.delete(args.agentId); // 该席位重跑,允许再次回传
// 不动全局 running —— 用按席位的 busy,这样可同时协助多个待确认的智能体。
setBusy(args.agentId, true);
const ac = new AbortController(); const ac = new AbortController();
abortRef.value = ac; acByAgent.set(args.agentId, ac);
try { try {
await streamParallelAgents( await streamParallelAgents(
cfg, cfg,
@@ -235,8 +277,10 @@ export function useParallelRun() {
if (dd.confidence === "并行执行中") dd.confidence = "已完成"; if (dd.confidence === "并行执行中") dd.confidence = "已完成";
} }
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
running.value = false; const st = seats.value.find((x) => x.agentId === args.agentId);
abortRef.value = null; settleAgent(args.agentId, settleStatusOf(st?.status ?? "done"));
acByAgent.delete(args.agentId);
setBusy(args.agentId, false);
} }
} }
@@ -250,11 +294,13 @@ export function useParallelRun() {
restored.forEach((d) => { restored.forEach((d) => {
bubbleByAgent.set(d.agentId, d.id); bubbleByAgent.set(d.agentId, d.id);
if (!seatMap.has(d.agentId)) { if (!seatMap.has(d.agentId)) {
const status: SeatStatus =
d.confidence === "出错" ? "error" : d.clarification ? "clarify" : "done";
seatMap.set(d.agentId, { seatMap.set(d.agentId, {
agentId: d.agentId, agentId: d.agentId,
name: d.sender, name: d.sender,
avatarType: d.avatarType, avatarType: d.avatarType,
status: d.confidence === "出错" ? "error" : "done", status,
bubbleId: d.id, bubbleId: d.id,
}); });
} }
@@ -266,9 +312,10 @@ export function useParallelRun() {
cancel(); cancel();
pending.clear(); pending.clear();
bubbleByAgent.clear(); bubbleByAgent.clear();
settled.clear();
dialogues.value = []; dialogues.value = [];
seats.value = []; seats.value = [];
} }
return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent }; return { dialogues, seats, running, busy, start, cancel, hydrate, reset, rerunAgent };
} }
@@ -1,282 +0,0 @@
/* 并行多智能体面板样式。
*
* 嵌入安全:只引 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);
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// 两种构建: // 两种构建:
// - 默认(`vite build` / `vite dev`):独立 demo 应用(index.html)。 // - 默认(`vite build` / `vite dev`):独立 demo 应用(index.html)。
@@ -10,7 +9,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const isLib = mode === "lib"; const isLib = mode === "lib";
return { return {
plugins: [vue(), tailwindcss()], plugins: [vue()],
resolve: { resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
}, },