This commit is contained in:
董宏凯 committed 2026-06-10 12:51:13 +08:00
1 parent ca9960c8bc
commit bd426ed1d3
24 files changed
+912 -108

No files matched your search

@@ -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` 作用域下,不与宿主类名冲突。
--- ---
+32 -12
View File
@@ -20,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。
--- ---
@@ -41,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**。
--- ---
@@ -446,6 +459,9 @@ function readOne(id: string) {
``` ```
`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。 `AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。
`status` 取值:`pending | running | done | clarify | error`。**`clarify` = 该智能体在等用户协助
(待确认),不是已完成**——宿主判断「是否全部完成」时应排除 `clarify`(例如
`results.every(r => r.status === "done")`),不要把待协助当成 done。
`defineExpose` 还暴露了 `reanalyze()` / `start()` / `save()` / `cancel()` / `loadTask(id)` 等方法及 `defineExpose` 还暴露了 `reanalyze()` / `start()` / `save()` / `cancel()` / `loadTask(id)` 等方法及
`running` / `saving` / `hasResults` / `canStart` / `history` 等状态,完整清单见 [§六·二](#六二暴露的方法与状态模板-ref)。 `running` / `saving` / `hasResults` / `canStart` / `history` 等状态,完整清单见 [§六·二](#六二暴露的方法与状态模板-ref)。
@@ -503,7 +519,10 @@ taskId.value = "task-001"; // 改了之后面板自动回显 task-
防子智能体模仿总控调度);检索、查看、`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常 防子智能体模仿总控调度);检索、查看、`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常
显示**,每个智能体只做自己那份分析。 显示**,每个智能体只做自己那份分析。
- 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。 - 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。
- 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。 - 顶部席位状态条显示每个智能体的进行中 / 完成 / **待协助** / 出错;待协助数量会单独标「N 待协助」。
- **用户协助(澄清)**:智能体要澄清时该席位变「待协助」(不是完成),气泡里弹协作卡片。多个智能体
同时待协助时**可分别点协助、互不阻塞**(协助 A 不会禁用 B/C 的卡片);提交回答后该席位自动带上
补充重跑。
- **任务名 / 简介 / 模型 / 按钮 / 历史下拉全部在宿主侧**:模型空 = `model` prop / 配置 / 后端回退; - **任务名 / 简介 / 模型 / 按钮 / 历史下拉全部在宿主侧**:模型空 = `model` prop / 配置 / 后端回退;
模型作用于**下一次**`reanalyze()`。 模型作用于**下一次**`reanalyze()`。
- `panel.save()` 存档;`panel.loadTask(id)` 回显;`panel.reanalyze()` 清空重跑;`panel.cancel()` 停止。 - `panel.save()` 存档;`panel.loadTask(id)` 回显;`panel.reanalyze()` 清空重跑;`panel.cancel()` 停止。
@@ -514,7 +533,8 @@ taskId.value = "task-001"; // 改了之后面板自动回显 task-
## 九、注意事项 ## 九、注意事项
- **不会污染宿主全局样式**:库未引入 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",
+3 -3
View File
@@ -6,10 +6,10 @@
* 「重新分析 / 保存 / 停止」等按钮由宿主渲染,通过模板 ref 调用面板 expose 出的方法。 * 「重新分析 / 保存 / 停止」等按钮由宿主渲染,通过模板 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,
+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 }>();
@@ -12,16 +12,16 @@
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。 * 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/ */
import { computed, onMounted, 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,
listTasks, listTasks,
updateTask, updateTask,
type ApiConfig, type ApiConfig,
} from "@/api"; } from "./api";
import { DEFAULT_BASE_URL } from "@/config"; import { DEFAULT_BASE_URL } from "./config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue"; import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue"; import MessageBubble from "./MessageBubble.vue";
@@ -77,7 +77,7 @@ const run = useParallelRun({
if (r) emit("agent-completed", r); if (r) emit("agent-completed", r);
}, },
}); });
const { dialogues, seats, running } = run; const { dialogues, seats, running, busy } = run;
// 任务名 / 简介 / 模型全部由宿主 props 提供(本组件不再持有可编辑表单)。 // 任务名 / 简介 / 模型全部由宿主 props 提供(本组件不再持有可编辑表单)。
const taskName = computed(() => props.taskName ?? ""); const taskName = computed(() => props.taskName ?? "");
@@ -339,7 +339,7 @@ onMounted(async () => {
<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">
@@ -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";
@@ -1,13 +1,11 @@
/* 并行多智能体面板样式。 /* 并行多智能体面板样式 —— 零依赖、不污染宿主。
*
* 不再依赖 Tailwind:本文件自带一份「最小布局工具类子集」(文件末尾),全部作用域在根类
* `.parallel-agents` 下,绝不外泄到宿主页面。其它项目把整个 parallel-agents/ 文件夹拷走、
* 只 `import "./parallel-agents.css"` 即可,无需安装或配置 Tailwind。
* *
* 嵌入安全:只引 Tailwind 的 theme + utilities(**不引 preflight**,避免重置宿主全局样式)。
* 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2 * 颜色用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》ZS-004 / ZS-002,与圆桌 Step2
* 深色一致),全部包在根类 `.parallel-agents` 下,绝不污染宿主。布局用 Tailwind 工具类, * 深色一致)。语义颜色用 `.pa-*` 类,布局用末尾的工具类子集。 */
* 主题色用下面的 `.pa-*` 语义类。 */
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
/* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */ /* ── 作用域 token(深色深蓝,自带成色,不依赖宿主主题)─────────────────────── */
.parallel-agents { .parallel-agents {
@@ -52,7 +50,7 @@
background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border)); background: color-mix(in oklch, var(--pa-primary) 60%, var(--pa-border));
} }
/* ── 语义颜色类(组件用这些上色,布局仍用 Tailwind 工具类)──────────────────── */ /* ── 语义颜色类(组件用这些上色,布局用文件末尾的工具类子集)──────────────────── */
.parallel-agents .pa-bg { .parallel-agents .pa-bg {
background-color: var(--pa-bg); background-color: var(--pa-bg);
} }
@@ -149,6 +147,10 @@
background-color: var(--pa-rose); background-color: var(--pa-rose);
color: #fff; color: #fff;
} }
.parallel-agents .pa-dot-clarify {
background-color: var(--pa-amber);
color: #fff;
}
/* 流式光标 + loading 点 */ /* 流式光标 + loading 点 */
.parallel-agents .pa-cursor { .parallel-agents .pa-cursor {
@@ -280,3 +282,175 @@
.parallel-agents .pa-md th { .parallel-agents .pa-md th {
background: var(--pa-muted); 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();
@@ -29,6 +29,18 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
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,去重,避免重复回传。 // 本轮已「结算」(回调过 onAgentSettled)的 agentId,去重,避免重复回传。
const settled = new Set<string>(); const settled = new Set<string>();
function settleAgent(agentId: string, status: SeatView["status"]) { function settleAgent(agentId: string, status: SeatView["status"]) {
@@ -61,6 +73,11 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
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;
@@ -125,9 +142,10 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
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, "done"); settleAgent(ev.agent_id, "clarify");
break; break;
} }
case "agent_error": { case "agent_error": {
@@ -204,8 +222,8 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
} }
} }
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
// 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态)。 // 兜底:没收到终态事件就断流的席位也回调一次(按当前席位状态,保留待协助/出错)。
for (const s of seats.value) settleAgent(s.agentId, s.status === "error" ? "error" : "done"); for (const s of seats.value) settleAgent(s.agentId, settleStatusOf(s.status));
running.value = false; running.value = false;
abortRef.value = null; abortRef.value = null;
} }
@@ -213,6 +231,9 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
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;
} }
@@ -224,7 +245,8 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
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 = "";
@@ -237,9 +259,10 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
settled.delete(args.agentId); // 该席位重跑,允许再次回传 settled.delete(args.agentId); // 该席位重跑,允许再次回传
running.value = true; // 不动全局 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,
@@ -255,9 +278,9 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
} }
dialogues.value = [...dialogues.value]; dialogues.value = [...dialogues.value];
const st = seats.value.find((x) => x.agentId === args.agentId); const st = seats.value.find((x) => x.agentId === args.agentId);
settleAgent(args.agentId, st?.status === "error" ? "error" : "done"); settleAgent(args.agentId, settleStatusOf(st?.status ?? "done"));
running.value = false; acByAgent.delete(args.agentId);
abortRef.value = null; setBusy(args.agentId, false);
} }
} }
@@ -271,11 +294,13 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
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,
}); });
} }
@@ -292,5 +317,5 @@ export function useParallelRun(opts: ParallelRunOptions = {}) {
seats.value = []; seats.value = [];
} }
return { dialogues, seats, running, start, cancel, hydrate, reset, rerunAgent }; return { dialogues, seats, running, busy, start, cancel, hydrate, reset, rerunAgent };
} }
+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)) },
}, },