Compare commits
13
Commits
master-nj
...
ab6068a7c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6068a7c1 | ||
|
|
5ab3d7f254 | ||
|
|
30e1c2dd90 | ||
|
|
673adef7df | ||
|
|
6f7919ded2 | ||
|
|
014d754707 | ||
|
|
8859c13cfb | ||
|
|
b0970de677 | ||
|
|
53cac5f81a | ||
|
|
4f7f07504a | ||
|
|
3ca5528640 | ||
|
|
f9b6d7e322 | ||
|
|
33dea724a1 |
No files matched your search
@@ -0,0 +1,186 @@
|
||||
# 报告协作工作台 — 交互重设计方案(v2)
|
||||
|
||||
> 日期:2026-09-07
|
||||
> 状态:**待确认**(确认后按 §7 分期实施)
|
||||
> 取代:现行「规则澄清 + 三张模板方案卡 + 画布为主」的交互
|
||||
> 原则:回到最初设计 —— **协调者智能体全程与用户对话的主线**
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计目标与总体形态
|
||||
|
||||
用户视角只有一条主线:**和一个规划师智能体对话 → 它摸清意图并规划工作流 → 确认后执行、过程在流程图上可见 → 随时插话干预、指定智能体重跑 → 最终成稿、继续对话式修改**。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 阶段1 计划模式 阶段2/3 执行+干预 阶段4 成稿 │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │
|
||||
│ │ 规划师对话 │ 确认 │ 聊天时间线 │ 审稿过 │ 对话改稿 │ │
|
||||
│ │ 需求摘要卡 │ ─────→ │ +成员Tab │ ─────→ │ 版本/diff│ │
|
||||
│ │ 工作流卡 │ 冻结 │ +流程图动画│ 报告卡 │ 上游重跑 │ │
|
||||
│ └───────────┘ └───────────┘ └──────────┘ │
|
||||
│ ↑ 回到计划模式(replan)随时可触发 ↑ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
与现行的核心差异:
|
||||
|
||||
| 维度 | 现行(做偏的) | v2 |
|
||||
|------|----------------|-----|
|
||||
| 需求澄清 | 规则模板问句秒回 | 规划师智能体(LLM)领域化提问 |
|
||||
| 工作流来源 | 3 张确定性模板 | LLM 按意图生成自定义 DAG,对话迭代修改 |
|
||||
| 执行展示 | 画布为主、聊天为辅 | **聊天时间线为主**(成员 Tab 切换保留),**执行状态在流程图上动画呈现** |
|
||||
| 干预 | 命令确认卡体系 | 输入框常开 → 协调者智能体分类;支持**指定智能体重跑** |
|
||||
| 报告修改 | 改写候选(纯文字层) | 意图分流:润色走改写;**事实性问题触发上游节点重跑** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段 1:计划模式 — 需求梳理与工作流规划
|
||||
|
||||
### 2.1 计划模式状态
|
||||
|
||||
- 用户发出第一条需求消息起进入**计划模式**,Header 显示徽章「📋 计划模式」,输入框提示语切换为「梳理需求中,你可以随时补充或纠正…」。
|
||||
- 计划模式在**工作流确认冻结**时结束;执行期大改可通过 replan 回到计划模式(见 §5.4)。
|
||||
|
||||
### 2.2 规划师对话(LLM,替换现行规则路由)
|
||||
|
||||
- 模型:`report_collaboration.planner_model`(回退 `coordinator_model` → 默认模型,复用现有 model_resolver 冻结机制)。
|
||||
- 提问是领域化的:写市场分析就问「关注整车还是供应链 / 要不要竞品对比 / 给谁看 / 篇幅」,而非固定模板问句。
|
||||
- 澄清轮次上限沿用 `max_clarification_rounds`;用户随时可以说「直接开始」按默认假设推进。
|
||||
|
||||
### 2.3 需求摘要卡
|
||||
|
||||
信息足够后,规划师在对话中输出一张**结构化需求摘要卡**(业务卡插槽渲染):主题 / 目的 / 读者 / 角度清单 / 篇幅与重点 / 默认假设。用户逐项看,可在对话里继续改(「读者改成投资人」→ 卡片更新出新版)。
|
||||
|
||||
### 2.4 工作流生成与迭代
|
||||
|
||||
- 用户确认摘要后,规划师生成**一份**工作流方案(不是三选一):以工作流卡(成员构成、各节点使命、预计时长/成本)+ 右侧流程图预览呈现。
|
||||
- 生成约束(保证可执行):**节点类型受词表约束** — `research / verify / analyze / write / review`;自由的是结构(几条线、并行还是串行、核验深度)与每个节点的使命/角度/工具。
|
||||
- 对话式修改:「把价格策略单独拆一条线」「加一个政策风险分析」→ 规划师修订后出新版工作流卡(带变更说明),再确认。
|
||||
- 「开始执行」→ 冻结需求 + 工作流(含模型/预算快照,沿用现有 freeze 机制)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段 2:执行 — 过程在流程图上可见
|
||||
|
||||
### 3.1 流程图动画规格(执行状态的可视化主载体)
|
||||
|
||||
节点状态 → 样式(`ReportFlowCanvas` / `ReportFlowNode`,状态源 `selectNodeStatusMap`,事件源 `node.status.changed` SSE):
|
||||
|
||||
| 状态 | 节点样式 | 连入边 |
|
||||
|------|----------|--------|
|
||||
| planned / queued | 灰色常态 | 静态 |
|
||||
| **running** | 主色描边 + **呼吸脉冲动画**(ring)+ 角标 spinner | **流动动画**(@xyflow 内置 `animated: true` 的 dash 流动) |
|
||||
| **completed** | 绿色 + ✓ 角标,常驻 | 恢复静态实线 |
|
||||
| failed | 红色 + 错误角标 | 红色虚线 |
|
||||
| awaiting_input | 橙色 + ?角标 | 橙色静态 |
|
||||
| interrupted / superseded | 半透明降级 | 灰 |
|
||||
|
||||
- 语义:**边流动 = 数据正在流入该节点**(该节点执行中);节点完成即停动画换完成样式。
|
||||
- 实现成本:@xyflow 原生支持 animated 边与自定义节点 className,纯 CSS 补呼吸动画即可,无需新依赖。
|
||||
|
||||
### 3.2 聊天区(保留现有 Tab 切换)
|
||||
|
||||
- 并行成员沿用 **AgentStageSelector Tab 切换**(维持现状,不改交错时间线)。
|
||||
- 每个 Tab 内是该成员的完整流式输出:思考、工具调用卡、检索过程、正文 —— 与普通智能体问答同观感(message-adapter 已具备映射能力)。
|
||||
- 协调者播报(「研究线 A 完成,核心发现 X,开始交叉核验」)进入总时间线 Tab。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段 3:干预 — 对话即干预 + 指定重跑
|
||||
|
||||
### 4.1 输入框常开,协调者智能体分流
|
||||
|
||||
执行期输入框永远可用,所有输入先到协调者(LLM 分类,规则高精度直判兜底——复用 RC-BE-014 IntentRouter 骨架,把「规则优先」反转为「LLM 优先 + 规则快路径」):
|
||||
|
||||
| 用户意图 | 协调者行为 | 生效时机 |
|
||||
|----------|-----------|----------|
|
||||
| 提问(「查到什么了?」) | 直接回答 | 立即,不打断 |
|
||||
| 修正方向(「重点看下沉市场」) | 小改:在安全点注入 overlay,并回复说明 | 下个安全点 |
|
||||
| **指定重跑**(见 §4.2) | 出确认卡 → 重开节点 attempt | 确认后 |
|
||||
| 大改(砍线/加线) | 提议 replan → 回计划模式 | 用户确认 |
|
||||
| 停止 | 直接取消 | 立即 |
|
||||
|
||||
### 4.2 指定智能体重跑(新增核心交互)
|
||||
|
||||
**场景**:用户发现某节点传给下游的信息不对(如研究线 B 的结论有误)。
|
||||
|
||||
两个入口,同一后端动作:
|
||||
|
||||
1. **对话**:「研究线 B 的结果不对,重新查,重点看价格战」→ 协调者解析出 `node_id` + 新指令;
|
||||
2. **画布**:点节点 → 抽屉(NodeInspectorDrawer)加「↻ 重新执行」按钮。
|
||||
|
||||
**级联规则**(复用并补全 ledger 的 targeted rework):
|
||||
|
||||
```
|
||||
重跑节点 N(attempt+1,带用户新指令作为 mission overlay)
|
||||
→ N 完成 → 所有直接/间接依赖 N 产物的下游节点标记 superseded
|
||||
→ 按 DAG 依赖序逐波重跑(边动画重新流动,用户全程可见)
|
||||
→ 受影响章节重新生成
|
||||
```
|
||||
|
||||
- 确认卡必须展示**影响面**:「将级联重跑 核验B / 分析 / 写作 共 3 个节点,预计 +8 分钟」。
|
||||
- 成本保护:级联重跑计入 run 预算(现有 budget 机制),预算不足时明确报因。
|
||||
|
||||
---
|
||||
|
||||
## 5. 阶段 4:报告成稿与对话式修改
|
||||
|
||||
### 5.1 成稿
|
||||
|
||||
审稿通过 → 报告卡出现在时间线(点开 ReportPreviewDialog 全文),协调者播报总结(各线核心发现 → 结论如何形成)。
|
||||
|
||||
### 5.2 修改意图分流(协调者 LLM 判定)
|
||||
|
||||
| 用户说 | 判定 | 动作 |
|
||||
|--------|------|------|
|
||||
| 「把第二章结论写得更笃定」 | 文字润色 | 改写候选(现有 rewrite API)→ 对比预览 → apply → 新版本 |
|
||||
| 「这个数据不对 / 重新收集一下」 | **事实性问题** | 定位来源节点 → **触发该节点重跑(可带新角度)→ 级联 §4.2 → 受影响章节重生成 → 新版本** |
|
||||
| 「再从供应链角度补一节」 | 结构性新增 | replan:回计划模式改工作流(只增不删已完成部分) |
|
||||
| 「恢复到上一版」 | 版本回退 | 现有 restore API |
|
||||
|
||||
- 版本链:每次 apply / restore / 级联重跑产出均产生新 head,历史完整保留(现有机制)。
|
||||
- 修改对话里协调者必须先**复述理解**(「你是想让研究线 B 换个角度重查,然后重写第三章,对吗?」)再执行,避免误触发昂贵的级联。
|
||||
|
||||
---
|
||||
|
||||
## 6. 工程改造清单
|
||||
|
||||
### 6.1 保留(本次联调已验证)
|
||||
|
||||
- 持久化 13 张表 + durable SSE + `after_seq` 断线续传;幂等键 / 乐观锁 / 预算框架。
|
||||
- MessageList 复用链路(message-adapter → LangGraph Message → 业务卡插槽)。
|
||||
- AgentStageSelector Tab、commands 安全点骨架(RC-BE-014)、报告版本三件套(RC-BE-015)、前端 hooks/reducer/selectors 主体。
|
||||
- 「清空历史」「生成候选方案」等本次新增入口。
|
||||
|
||||
### 6.2 后端重做 / 新建
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 规划师智能体 | LLM 多轮澄清(替换 `requirement/intent_router.py` 规则路由)+ 需求摘要结构化 + **工作流 LLM 生成**(受 §2.4 词表约束,替换 `planning/controller.py` 三模板)+ 对话式工作流修订 |
|
||||
| 协调者智能体升级 | 干预分类 LLM 优先;修改意图分流(§5.2);执行前复述确认 |
|
||||
| 指定重跑 + 级联 | ledger targeted rework 补全级联 superseded/重跑波次;确认卡带影响面计算 |
|
||||
| AgentScope 适配器修复 | `count_tokens` 协议缺失(上下文压缩必崩);attempt 耗尽终态化(防僵尸 run) |
|
||||
| 事件补齐 | 边流动所需的节点状态事件已有;补「级联影响预告」事件 |
|
||||
|
||||
### 6.3 前端重做 / 新建
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 计划模式 UI | Header 徽章、composer 提示切换、需求摘要卡、工作流卡(替换现三张模板卡) |
|
||||
| 流程图动画 | running 节点呼吸动画 + 入边 `animated`、completed ✓ 样式、级联重跑时边动画重流(§3.1) |
|
||||
| 节点抽屉 | 加「重新执行」按钮 → 确认卡 |
|
||||
| 干预交互 | 执行期输入框常开;意图确认卡(含影响面);修改对话复述确认 |
|
||||
| 布局 | 聊天为主、画布为辅的默认比例微调(不动整体两栏结构) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施分期
|
||||
|
||||
| 期 | 内容 | 验收 |
|
||||
|----|------|------|
|
||||
| **P0 地基** | AgentScope `count_tokens` 修复;attempt 耗尽终态化;级联重跑的 ledger 补全 | 执行阶段不崩、无僵尸 run、单节点重跑级联正确(后端测试) |
|
||||
| **P1 计划模式 + 执行可视化** | 规划师 LLM 对话、需求摘要卡、工作流 LLM 生成与迭代、计划模式 UI、流程图动画 | 从新会话到确认工作流全程对话完成;执行时动画正确随状态流转 |
|
||||
| **P2 干预 + 成稿修改** | 协调者 LLM 分类、指定重跑(两入口 + 影响面确认卡)、报告修改意图分流、上游级联重跑 | 四类干预各自走通;「数据不对→重跑→新章节→新版本」全链路 |
|
||||
|
||||
每期结束跑 RC 全量测试(当前基线 233 通过)+ 前端 26 单测,不引入对其他模块的影响。
|
||||
@@ -6,13 +6,22 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
loadKnowledgeBaseProductVersion,
|
||||
loadOrdinaryKnowledgeLanding,
|
||||
saveOrdinaryKnowledgeLanding,
|
||||
type OrdinaryKnowledgeLanding,
|
||||
type KnowledgeBaseProductVersion,
|
||||
updateKnowledgeBaseProductVersion,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||
|
||||
export function useOrdinaryKnowledgeLanding() {
|
||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||
}
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
|
||||
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
const version = useKnowledgeBaseProductVersion();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const saveLanding = useMutation({
|
||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const save = useMutation({
|
||||
mutationFn: updateKnowledgeBaseProductVersion,
|
||||
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||
</Select>
|
||||
默认产品版本
|
||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
|
||||
@@ -205,21 +205,21 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
||||
/>
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm">{target.target_name}</span>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||
<p className="p-3 text-xs text-destructive">
|
||||
读取 WeKnora 普通知识库失败:
|
||||
读取普通知识库失败:
|
||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||
</p>
|
||||
)}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{runtime.data?.provider !== "weknora"
|
||||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,22 @@ export interface PromptPrefixSettings {
|
||||
|
||||
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
||||
|
||||
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
|
||||
|
||||
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
||||
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
||||
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||
}
|
||||
|
||||
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
|
||||
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||
}
|
||||
|
||||
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Neutral product terminology for system messages; never apply to knowledge content or API identifiers. */
|
||||
export function userFacingKnowledgeMessage(value: string): string {
|
||||
return value.replace(/weknora/gi, "知识服务");
|
||||
}
|
||||
@@ -142,7 +142,7 @@ const DRAFT_ID = 'aw-draft';
|
||||
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
|
||||
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
|
||||
const AI_WRITING_LAST_AGENT_KEY = `agent-module:${AI_WRITING_AGENT_MODULE_KEY}:last-agent`;
|
||||
const SHOW_NOTEBOOK_SPACE_ENTRIES = false;
|
||||
const SHOW_NOTEBOOK_SPACE_ENTRIES = true;
|
||||
|
||||
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import "@/styles/agents-orbit.css";
|
||||
import "@/styles/agents-orbit-dark.css";
|
||||
import "@/styles/agents-orbit-light.css";
|
||||
|
||||
/** 转盘旋转过渡时长(ms),与 CSS 中 .ao-type-arm 的 transition 保持一致。 */
|
||||
const DIAL_TRANSITION_MS = 850;
|
||||
|
||||
/** 一个类型 = 一个标签(取智能体的第一个标签;无标签归入「未分类」)。 */
|
||||
interface AgentGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
function groupAgentsByType(agents: Agent[]): AgentGroup[] {
|
||||
const map = new Map<string, AgentGroup>();
|
||||
for (const agent of agents) {
|
||||
const tag = agent.tags?.[0];
|
||||
const id = tag?.id ?? "__uncategorized__";
|
||||
let group = map.get(id);
|
||||
if (!group) {
|
||||
group = { id, name: tag?.name ?? "未分类", agents: [] };
|
||||
map.set(id, group);
|
||||
}
|
||||
group.agents.push(agent);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "zh-Hans-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
const mod = (value: number, n: number): number => ((value % n) + n) % n;
|
||||
|
||||
/**
|
||||
* 密码转盘模型:
|
||||
* - n 个类型始终均匀占据右半圆的 n 个固定槽位(间距恒定,不随点击改变);
|
||||
* - 激活槽位固定为最贴近正右侧中间的槽,被点击的类型旋转到该槽;
|
||||
* - 点击上半部分 → 转盘顺时针转(k > 0),下半部分 → 逆时针(k < 0);
|
||||
* - 超出半圆端点的标签沿“背面长路”补位(角度 ±360 扫过左半圆,途中半透明),
|
||||
* 动画结束后角度归一化回槽位角(同一位置 mod 360,无视觉跳变)。
|
||||
*/
|
||||
export default function AgentsOrbitPage() {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const isDark = themeToBaseMode(theme) === "dark";
|
||||
const { agents, isLoading } = useAgents();
|
||||
const groups = useMemo(() => groupAgentsByType(agents), [agents]);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
/** 转盘动画期间的非归一化角度(含 ±360 长路,长路全程在左半圆不可视区内)。 */
|
||||
const [anim, setAnim] = useState<Record<string, number> | null>(null);
|
||||
/** 归一化帧:瞬移到等价角度时禁用过渡。 */
|
||||
const [frozen, setFrozen] = useState(false);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
|
||||
<div className="ao-state">
|
||||
<div className="ao-spinner" />
|
||||
<span>正在加载智能体…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className={`agents-orbit-page ${isDark ? "ao-dark" : "ao-light"}`}>
|
||||
<div className="ao-state">
|
||||
<span>还没有可展示的智能体</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const n = groups.length;
|
||||
const midSlot = Math.floor((n - 1) / 2);
|
||||
const step = 180 / n;
|
||||
/** 槽位 j 的世界角:-90°(正上) → +90°(正下),均布在右半圆开区间内。 */
|
||||
const slotAngle = (slot: number) => -90 + (slot + 0.5) * step;
|
||||
|
||||
const requestedIdx = activeId
|
||||
? groups.findIndex((g) => g.id === activeId)
|
||||
: -1;
|
||||
const activeIdx = requestedIdx >= 0 ? requestedIdx : midSlot;
|
||||
const active = groups[activeIdx];
|
||||
/** 静态分组数组的旋转量:group(gi) 落在槽位 (gi + shift) mod n,激活组恰在 midSlot。 */
|
||||
const shift = midSlot - activeIdx;
|
||||
const groupAtSlot = (slot: number) => groups[mod(slot - shift, n)];
|
||||
|
||||
const handleTypeClick = (slot: number) => {
|
||||
const group = groupAtSlot(slot);
|
||||
if (group.id === active.id) return;
|
||||
|
||||
const k = midSlot - slot; // 正=顺时针向下转,负=逆时针向上转
|
||||
const angles: Record<string, number> = {};
|
||||
for (let j = 0; j < n; j++) {
|
||||
const g = groupAtSlot(j);
|
||||
const nextSlot = j + k;
|
||||
let angle = slotAngle(mod(nextSlot, n));
|
||||
if (nextSlot >= n) {
|
||||
angle += 360; // 从底部滑出(进入左半圆不可视区),绕背回顶部补位
|
||||
} else if (nextSlot < 0) {
|
||||
angle -= 360; // 从顶部滑出(进入左半圆不可视区),绕背回底部补位
|
||||
}
|
||||
angles[g.id] = angle;
|
||||
}
|
||||
|
||||
setActiveId(group.id);
|
||||
setAnim(angles);
|
||||
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
// 动画结束:归一化长路角度(同一位置 mod 360),下一帧恢复过渡。
|
||||
setFrozen(true);
|
||||
setAnim(null);
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => setFrozen(false)),
|
||||
);
|
||||
}, DIAL_TRANSITION_MS + 60);
|
||||
};
|
||||
|
||||
const handleAgentClick = (agent: Agent) => {
|
||||
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
|
||||
};
|
||||
|
||||
const themeClass = isDark ? "ao-dark" : "ao-light";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`agents-orbit-page ${themeClass}${n >= 8 ? " ao-crowded" : ""}`}
|
||||
>
|
||||
<div className="ao-header">
|
||||
<span className="ao-header-title">智能体 · 星轨转盘</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ao-header-link"
|
||||
onClick={() => router.push("/page/workspace/agents")}
|
||||
>
|
||||
经典视图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 左侧星轨舞台:中心圆盘 + 中心图片 + 三层圆环 + 卫星 + 类型转盘 */}
|
||||
<div className="ao-stage">
|
||||
<div className="ao-root">
|
||||
<div className="ao-ring ao-ring--ticks" />
|
||||
<div className="ao-ring ao-ring--dash" />
|
||||
<div className="ao-ring ao-ring--arc" />
|
||||
<div className="ao-sat-orbit">
|
||||
<div className="ao-sat" />
|
||||
</div>
|
||||
|
||||
<div className="ao-core">
|
||||
<div className="ao-core-img">
|
||||
<Bot size={60} strokeWidth={1.4} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转盘容器:左半圆被裁剪为不可视区域,绕背旋转不可见 */}
|
||||
<div className="ao-types">
|
||||
{groups.map((group, gi) => {
|
||||
const slot = mod(gi + shift, n);
|
||||
const angle = anim
|
||||
? (anim[group.id] ?? slotAngle(slot))
|
||||
: slotAngle(slot);
|
||||
const isActive = group.id === active.id;
|
||||
const transitionStyle = frozen ? { transition: "none" } : {};
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="ao-type-arm"
|
||||
style={{
|
||||
transform: `rotate(${angle}deg)`,
|
||||
...transitionStyle,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`ao-type-anchor${isActive ? " is-active" : ""}`}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) rotate(${-angle}deg)`,
|
||||
...transitionStyle,
|
||||
}}
|
||||
onClick={() => handleTypeClick(slot)}
|
||||
title={`${group.name} · ${group.agents.length} 个智能体`}
|
||||
>
|
||||
<span
|
||||
className="ao-type-chip"
|
||||
style={{ animationDelay: `${-gi * 0.8}s` }}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧面板:当前类型下的智能体(圆角正方形图标 + 名称),与舞台 1:1 */}
|
||||
<aside className="ao-panel">
|
||||
<div className="ao-panel-head">
|
||||
<div className="ao-panel-title">{active.name}</div>
|
||||
<div className="ao-panel-sub">共 {active.agents.length} 个智能体</div>
|
||||
</div>
|
||||
{active.agents.length > 0 ? (
|
||||
<div className="ao-agents" key={active.id}>
|
||||
{active.agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className="ao-agent"
|
||||
onClick={() => handleAgentClick(agent)}
|
||||
title={agent.description || agent.name}
|
||||
>
|
||||
<span className="ao-agent-icon">
|
||||
{(agent.name || "?").trim().charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="ao-agent-name">{agent.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ao-state">
|
||||
<span>该类型下暂时没有智能体</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpenText,
|
||||
@@ -67,6 +68,7 @@ export default function AssistantKnowledgePage() {
|
||||
ReturnType<typeof searchAssistantWiki>
|
||||
> | null>(null);
|
||||
const upload = useRef<HTMLInputElement>(null);
|
||||
const documentUpload = useRef<HTMLInputElement>(null);
|
||||
const base = bases.data?.find((b) => b.id === baseId);
|
||||
const libraryPages = useMemo<LibraryWikiPage[]>(
|
||||
() =>
|
||||
@@ -159,8 +161,7 @@ export default function AssistantKnowledgePage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">助手知识库</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
浏览分类
|
||||
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
|
||||
选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
@@ -306,6 +307,12 @@ export default function AssistantKnowledgePage() {
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button size="sm" disabled={actions.uploadDocument.isPending} onClick={() => documentUpload.current?.click()}>
|
||||
{actions.uploadDocument.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
|
||||
上传文件生成 Wiki
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -314,10 +321,23 @@ export default function AssistantKnowledgePage() {
|
||||
onClick={() => upload.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 size-4" />
|
||||
导入数据包
|
||||
导入 普通知识库数据包
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input ref={documentUpload} type="file" multiple accept=".md,.markdown,.txt,.csv,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp" className="hidden"
|
||||
onChange={async (event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
setTab("transfer");
|
||||
for (const file of files) {
|
||||
if (file.size > 100 * 1024 * 1024) { toast.error(`${file.name} 超过 100 MB,请拆分后上传`); continue; }
|
||||
try {
|
||||
await actions.uploadDocument.mutateAsync({ file, assistantBaseId: baseId });
|
||||
toast.success(`${file.name} 已上传,后台正在解析、生成 Wiki 并向量化`);
|
||||
} catch (error) { toast.error(`${file.name}:${(error as Error).message}`); }
|
||||
}
|
||||
}} />
|
||||
<input
|
||||
ref={upload}
|
||||
type="file"
|
||||
@@ -441,7 +461,8 @@ export default function AssistantKnowledgePage() {
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
上次同步:{new Date(s.updated_at).toLocaleString()}
|
||||
</p>
|
||||
{isAdmin && (
|
||||
{s.source_type === "file" && <p className="mt-2 text-xs text-muted-foreground">本地上传文档 · 处理进度及重试请查看导入记录</p>}
|
||||
{isAdmin && s.source_type === "weknora" && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
@@ -469,8 +490,10 @@ export default function AssistantKnowledgePage() {
|
||||
>
|
||||
<WikiPackageTransfers id={baseId} assistant />
|
||||
<h2 className="text-base font-semibold">导入与同步记录</h2>
|
||||
<p className="text-sm text-muted-foreground">可直接上传文档,独立生成 Wiki 并进行向量检索,无需配置普通知识库服务。支持 PDF、Word、Excel、PowerPoint、Markdown、TXT、CSV,单文件最大 100 MB。生成失败时可配置语言模型或编码器后重试。</p>
|
||||
{jobs.data?.map((j) => (
|
||||
<div key={j.id} className="rounded-xl border p-4">
|
||||
<p className="mb-2 font-medium">{sources.data?.find((source) => source.id === j.source_id)?.source_name || (j.trigger === "local_file_upload" ? "上传文档" : "知识数据同步")}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">
|
||||
{{
|
||||
@@ -484,6 +507,9 @@ export default function AssistantKnowledgePage() {
|
||||
{new Date(j.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{({ uploading: "接收文件", parsing: "解析文档", generating_wiki: "生成 Wiki", vectorizing: "正文向量化", importing: "保存知识并同步总库", queued: "等待处理", interrupted: "处理已中断" } as Record<string, string>)[j.phase] || j.phase}</p>
|
||||
{j.counts?.parts_total > 0 && <p className="text-xs text-muted-foreground">文档分段:{j.counts.parts_done || 0} / {j.counts.parts_total}</p>}
|
||||
{j.counts?.vectors_total > 0 && <p className="text-xs text-muted-foreground">向量化:{j.counts.vectors || 0} / {j.counts.vectors_total}</p>}
|
||||
<p className="mt-3 text-sm">
|
||||
{j.counts?.wiki_pages || 0} Wiki ·{" "}
|
||||
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
|
||||
@@ -491,9 +517,15 @@ export default function AssistantKnowledgePage() {
|
||||
</p>
|
||||
{j.last_error_message && (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
{j.last_error_message}
|
||||
{userFacingKnowledgeMessage(j.last_error_message)}
|
||||
</p>
|
||||
)}
|
||||
{j.trigger === "local_file_upload" && j.status !== "completed" && (
|
||||
<Button className="mt-2" size="sm" variant="outline" disabled={actions.retryDocument.isPending} onClick={async () => {
|
||||
try { await actions.retryDocument.mutateAsync(j.id); toast.success("已继续处理,复用已有解析和向量结果"); }
|
||||
catch (error) { toast.error((error as Error).message); }
|
||||
}}>重试 / 恢复处理</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
|
||||
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
||||
}
|
||||
if (event.type === "completed") toast.success("企业研究报告已完成");
|
||||
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
|
||||
if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
|
||||
}
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
|
||||
return;
|
||||
}
|
||||
if (!isWeKnoraReady) {
|
||||
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
|
||||
toast.error("知识服务尚未启用,请先在系统中配置知识服务");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
|
||||
<section>
|
||||
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
||||
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
@@ -609,12 +610,12 @@ function TaskOverview({
|
||||
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
||||
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
||||
const current = task.status === "collecting" && index === 1;
|
||||
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
||||
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用知识服务检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
||||
})}</ol></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
|
||||
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</CardDescription></CardHeader>
|
||||
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
|
||||
</Card>
|
||||
{canPlan ? <Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") {
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
|
||||
if (mode === "list" || mode === "detail") {
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
Clock3,
|
||||
GitBranch,
|
||||
LoaderCircle,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
PanelLeftClose,
|
||||
Pause,
|
||||
Play,
|
||||
@@ -41,10 +43,25 @@ import {
|
||||
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
||||
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
||||
} from "@/core/official-deerflow/routes";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
import MultiAgentTasksPage from "./official-multi-agent-tasks/MultiAgentTasksPage";
|
||||
import TaskEditPage from "./official-multi-agent-tasks/TaskEditPage";
|
||||
import TaskAnalysisPage from "./official-task-analysis/TaskAnalysisPage";
|
||||
import TaskAnalysisDetailPage from "./official-task-analysis/TaskAnalysisDetailPage";
|
||||
|
||||
import "./official-deerflow-entry.css";
|
||||
|
||||
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
|
||||
type OfficialPage =
|
||||
| "chat"
|
||||
| "agents"
|
||||
| "agent-chat"
|
||||
| "scheduled"
|
||||
| "multi-agent-tasks"
|
||||
| "multi-agent-edit"
|
||||
| "task-analysis"
|
||||
| "task-analysis-detail"
|
||||
| "settings";
|
||||
|
||||
function pageFromPath(pathname: string): OfficialPage {
|
||||
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||
@@ -53,6 +70,13 @@ function pageFromPath(pathname: string): OfficialPage {
|
||||
}
|
||||
if (path.startsWith("/workspace/agents")) return "agents";
|
||||
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
||||
if (path.startsWith("/workspace/multi-agent-tasks/new")) return "multi-agent-edit";
|
||||
if (/^\/workspace\/multi-agent-tasks\/[^/]+\/edit$/.test(path)) {
|
||||
return "multi-agent-edit";
|
||||
}
|
||||
if (path.startsWith("/workspace/multi-agent-tasks")) return "multi-agent-tasks";
|
||||
if (/^\/workspace\/task-analysis\/[^/]+/.test(path)) return "task-analysis-detail";
|
||||
if (path.startsWith("/workspace/task-analysis")) return "task-analysis";
|
||||
if (path.startsWith("/workspace/settings")) return "settings";
|
||||
return "chat";
|
||||
}
|
||||
@@ -78,6 +102,33 @@ function parseScheduledTaskId(pathname: string) {
|
||||
return match ? decodeURIComponent(match[1]!) : null;
|
||||
}
|
||||
|
||||
function parseMultiAgentTaskId(pathname: string) {
|
||||
const match = pathname.match(/\/workspace\/multi-agent-tasks\/([^/]+)/);
|
||||
const raw = match ? decodeURIComponent(match[1]!) : null;
|
||||
// 编辑页路由(new / :id/edit)不是任务详情,不驱动列表页的抽屉。
|
||||
if (!raw || raw === "new") return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseTaskAnalysisId(pathname: string) {
|
||||
const match = pathname.match(/\/workspace\/task-analysis\/([^/]+)/);
|
||||
return match ? decodeURIComponent(match[1]!) : null;
|
||||
}
|
||||
|
||||
function parseMultiAgentEditTarget(pathname: string): {
|
||||
mode: "create" | "edit";
|
||||
taskId: string | null;
|
||||
} {
|
||||
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||
if (path.startsWith("/workspace/multi-agent-tasks/new")) {
|
||||
return { mode: "create", taskId: null };
|
||||
}
|
||||
const match = path.match(/^\/workspace\/multi-agent-tasks\/([^/]+)\/edit$/);
|
||||
return match
|
||||
? { mode: "edit", taskId: decodeURIComponent(match[1]!) }
|
||||
: { mode: "create", taskId: null };
|
||||
}
|
||||
|
||||
function readableError(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
@@ -658,6 +709,18 @@ export default function OfficialDeerFlowEntry() {
|
||||
() => parseScheduledTaskId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const multiAgentTaskId = useMemo(
|
||||
() => parseMultiAgentTaskId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const multiAgentEditTarget = useMemo(
|
||||
() => parseMultiAgentEditTarget(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const taskAnalysisId = useMemo(
|
||||
() => parseTaskAnalysisId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
||||
useThreads({
|
||||
limit: 20,
|
||||
@@ -672,6 +735,8 @@ export default function OfficialDeerFlowEntry() {
|
||||
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
||||
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
||||
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
||||
{ icon: Network, label: "多智能体定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`, pages: ["multi-agent-tasks", "multi-agent-edit"] },
|
||||
{ icon: GitBranch, label: "任务分析定时", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis`, pages: ["task-analysis", "task-analysis-detail"] },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
@@ -769,8 +834,23 @@ export default function OfficialDeerFlowEntry() {
|
||||
/>
|
||||
)}
|
||||
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
||||
{page === "multi-agent-tasks" && (
|
||||
<MultiAgentTasksPage routeTaskId={multiAgentTaskId} />
|
||||
)}
|
||||
{page === "multi-agent-edit" && (
|
||||
<TaskEditPage
|
||||
key={multiAgentEditTarget.taskId ?? "create"}
|
||||
mode={multiAgentEditTarget.mode}
|
||||
taskId={multiAgentEditTarget.taskId}
|
||||
/>
|
||||
)}
|
||||
{page === "task-analysis" && <TaskAnalysisPage />}
|
||||
{page === "task-analysis-detail" && (
|
||||
<TaskAnalysisDetailPage routeTaskId={taskAnalysisId} />
|
||||
)}
|
||||
{page === "settings" && <SettingsPage />}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import ChatPage from "./ChatPage";
|
||||
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
||||
import IframeChatPage from "./IframeChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentsOrbitPage from "./AgentsOrbitPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import SentimentAgentChatPage from "./SentimentAgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
@@ -620,6 +621,16 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* 智能体星轨视图:/agents 的另一种展示形式(手写 CSS 圆环星轨布局)。
|
||||
字面量路由,置于 agents/:agent_id/* 参数路由之前。 */}
|
||||
<Route
|
||||
path="agents/orbit"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AgentsOrbitPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
|
||||
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
Clock3,
|
||||
History,
|
||||
LoaderCircle,
|
||||
Network,
|
||||
Pencil,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, DialogPlugin, Drawer, Input, Popconfirm, Switch, Tabs, Tag } from "tdesign-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||
|
||||
import AgentFlow from "./components/AgentFlow";
|
||||
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
|
||||
import type { MatRunView, MatTaskView } from "./types";
|
||||
import {
|
||||
describeCron,
|
||||
expandStages,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatRelative,
|
||||
runStatusLabel,
|
||||
taskBadgeOf,
|
||||
} from "./utils";
|
||||
import "./multi-agent-tasks.css";
|
||||
|
||||
type StatusFilter = "all" | "running" | "enabled" | "paused";
|
||||
|
||||
function isTaskLive(task: MatTaskView) {
|
||||
return task.lastRun?.status === "running" || task.lastRun?.status === "pending";
|
||||
}
|
||||
|
||||
function matchFilter(task: MatTaskView, filter: StatusFilter) {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "running") return isTaskLive(task);
|
||||
if (filter === "paused") return !task.enabled;
|
||||
return task.enabled && !isTaskLive(task);
|
||||
}
|
||||
|
||||
function runTagTheme(status: MatRunView["status"]): "success" | "danger" | "warning" | "default" {
|
||||
if (status === "succeeded") return "success";
|
||||
if (status === "failed") return "danger";
|
||||
if (status === "running") return "warning";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export default function MultiAgentTasksPage({
|
||||
routeTaskId,
|
||||
}: {
|
||||
routeTaskId: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { tasks, isLoading } = useMultiAgentTasks();
|
||||
const actions = useMultiAgentTaskActions();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||
|
||||
const selectedTask = tasks.find((task) => task.id === routeTaskId) ?? null;
|
||||
|
||||
const openTask = useCallback(
|
||||
(taskId: string) => {
|
||||
setActiveRunId(null);
|
||||
navigate(
|
||||
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(taskId)}`,
|
||||
);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const closeTask = useCallback(() => {
|
||||
setActiveRunId(null);
|
||||
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
|
||||
}, [navigate]);
|
||||
|
||||
const visibleTasks = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return tasks.filter((task) => {
|
||||
if (!matchFilter(task, filter)) return false;
|
||||
if (!keyword) return true;
|
||||
return (
|
||||
task.name.toLowerCase().includes(keyword) ||
|
||||
(task.description ?? "").toLowerCase().includes(keyword) ||
|
||||
task.seats.some((seat) => seat.name.toLowerCase().includes(keyword))
|
||||
);
|
||||
});
|
||||
}, [tasks, search, filter]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = tasks.length;
|
||||
const enabled = tasks.filter((task) => task.enabled).length;
|
||||
const running = tasks.filter(isTaskLive).length;
|
||||
const runTotal = tasks.reduce((sum, task) => sum + task.runs.length, 0);
|
||||
return { total, enabled, running, runTotal };
|
||||
}, [tasks]);
|
||||
|
||||
const openCreate = () => {
|
||||
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/new`);
|
||||
};
|
||||
|
||||
const openEdit = (task: MatTaskView) => {
|
||||
navigate(
|
||||
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks/${encodeURIComponent(task.id)}/edit`,
|
||||
);
|
||||
};
|
||||
|
||||
const runTrigger = async (task: MatTaskView) => {
|
||||
setBusyTaskId(task.id);
|
||||
try {
|
||||
await actions.trigger.mutateAsync(task.id);
|
||||
setActiveRunId(null);
|
||||
openTask(task.id);
|
||||
toast.success("已触发执行");
|
||||
} catch {
|
||||
toast.error("触发失败,请重试");
|
||||
} finally {
|
||||
setBusyTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runToggle = async (task: MatTaskView) => {
|
||||
setBusyTaskId(task.id);
|
||||
try {
|
||||
await actions.setEnabled.mutateAsync({ id: task.id, enabled: !task.enabled });
|
||||
toast.success(task.enabled ? "任务已暂停" : "任务已恢复");
|
||||
} catch {
|
||||
toast.error("操作失败,请重试");
|
||||
} finally {
|
||||
setBusyTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (task: MatTaskView) => {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: "删除定时任务",
|
||||
body: `确定删除「${task.name}」吗?其编排配置与运行记录将一并清除,且不可恢复。`,
|
||||
confirmBtn: { content: "删除", theme: "danger" },
|
||||
cancelBtn: "取消",
|
||||
onConfirm: () => {
|
||||
dialog.destroy();
|
||||
void actions.remove.mutateAsync(task.id).then(() => {
|
||||
if (selectedTask?.id === task.id) closeTask();
|
||||
toast.success("任务已删除");
|
||||
});
|
||||
},
|
||||
onClose: () => dialog.destroy(),
|
||||
});
|
||||
};
|
||||
|
||||
const selectedStages = selectedTask ? expandStages(selectedTask) : [];
|
||||
const selectedRuns = selectedTask?.runs ?? [];
|
||||
const activeRun =
|
||||
selectedRuns.find((run) => run.id === activeRunId) ?? selectedRuns[0] ?? null;
|
||||
|
||||
return (
|
||||
<section className="mat-page">
|
||||
<header className="mat-page-head">
|
||||
<div className="mat-page-head-copy">
|
||||
<h1>多智能体定时任务</h1>
|
||||
<p>
|
||||
把多个智能体编排成协作编队:同阶段并行、阶段间串行,按计划自动执行并沉淀运行记录。
|
||||
</p>
|
||||
</div>
|
||||
<Button theme="primary" onClick={openCreate}>
|
||||
<Plus aria-hidden="true" size={15} />
|
||||
新建编排任务
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="mat-stats" aria-label="任务统计">
|
||||
<div className="mat-stat">
|
||||
<span className="mat-stat-icon is-primary" aria-hidden="true">
|
||||
<Network size={18} />
|
||||
</span>
|
||||
<div className="mat-stat-copy">
|
||||
<strong>{stats.total}</strong>
|
||||
<span>编排任务</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mat-stat">
|
||||
<span className="mat-stat-icon is-success" aria-hidden="true">
|
||||
<CircleCheck size={18} />
|
||||
</span>
|
||||
<div className="mat-stat-copy">
|
||||
<strong>{stats.enabled}</strong>
|
||||
<span>已启用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mat-stat">
|
||||
<span className={`mat-stat-icon${stats.running ? " is-live" : " is-muted"}`} aria-hidden="true">
|
||||
{stats.running ? <LoaderCircle size={18} className="mat-spin" /> : <Clock3 size={18} />}
|
||||
</span>
|
||||
<div className="mat-stat-copy">
|
||||
<strong className={stats.running ? "is-live" : undefined}>{stats.running}</strong>
|
||||
<span>执行中</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mat-stat">
|
||||
<span className="mat-stat-icon is-info" aria-hidden="true">
|
||||
<History size={18} />
|
||||
</span>
|
||||
<div className="mat-stat-copy">
|
||||
<strong>{stats.runTotal}</strong>
|
||||
<span>累计执行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mat-body">
|
||||
<div className="mat-toolbar">
|
||||
<div className="mat-toolbar-search">
|
||||
<Input
|
||||
clearable
|
||||
placeholder="搜索任务名称 / 描述 / 智能体"
|
||||
prefixIcon={<Search aria-hidden="true" size={15} />}
|
||||
value={search}
|
||||
onChange={(value) => setSearch(String(value ?? ""))}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
className="mat-toolbar-tabs"
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as StatusFilter)}
|
||||
>
|
||||
<Tabs.TabPanel label="全部" value="all" />
|
||||
<Tabs.TabPanel label="执行中" value="running" />
|
||||
<Tabs.TabPanel label="已启用" value="enabled" />
|
||||
<Tabs.TabPanel label="已暂停" value="paused" />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mat-state">
|
||||
<Clock3 aria-hidden="true" className="mat-spin" size={18} />
|
||||
正在加载编排任务…
|
||||
</div>
|
||||
) : visibleTasks.length ? (
|
||||
<div className="mat-grid">
|
||||
{visibleTasks.map((task) => {
|
||||
const badge = taskBadgeOf(task);
|
||||
const live = isTaskLive(task);
|
||||
const busy = busyTaskId === task.id;
|
||||
return (
|
||||
<article className="mat-card" key={task.id}>
|
||||
<header className="mat-card-head" onClick={() => openTask(task.id)}>
|
||||
<span className="mat-card-glyph" aria-hidden="true">
|
||||
<Network size={17} />
|
||||
</span>
|
||||
<div className="mat-card-title">
|
||||
<h3>{task.name}</h3>
|
||||
<p>
|
||||
<CalendarClock aria-hidden="true" size={13} />
|
||||
{describeCron(task.cron_expr)}
|
||||
</p>
|
||||
</div>
|
||||
<Tag size="small" theme={badge.theme} variant="light">
|
||||
{badge.label}
|
||||
</Tag>
|
||||
</header>
|
||||
{task.description && (
|
||||
<p className="mat-card-desc" onClick={() => openTask(task.id)}>
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mat-card-flow" onClick={() => openTask(task.id)}>
|
||||
<AgentFlow
|
||||
seats={task.seats}
|
||||
size="sm"
|
||||
stages={expandStages(task)}
|
||||
/>
|
||||
</div>
|
||||
<footer className="mat-card-foot">
|
||||
<div className="mat-card-meta">
|
||||
<span title={task.nextRunAt ?? undefined}>
|
||||
下次 {task.enabled ? formatDateTime(task.nextRunAt) : "已暂停"}
|
||||
</span>
|
||||
<span>
|
||||
上次 {task.lastRun ? formatRelative(task.lastRun.started_at) : "未执行"}
|
||||
{task.lastRun && task.lastRun.status !== "pending" && (
|
||||
<em className={`mat-run-text is-${task.lastRun.status}`}>
|
||||
· {runStatusLabel(task.lastRun.status)}
|
||||
</em>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mat-card-ops">
|
||||
<Button
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
onClick={() => void runTrigger(task)}
|
||||
>
|
||||
<Zap aria-hidden="true" size={14} />
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => void runToggle(task)}
|
||||
>
|
||||
{task.enabled ? (
|
||||
<Pause aria-hidden="true" size={14} />
|
||||
) : (
|
||||
<Play aria-hidden="true" size={14} />
|
||||
)}
|
||||
{task.enabled ? "暂停" : "恢复"}
|
||||
</Button>
|
||||
<Button size="small" variant="text" onClick={() => openEdit(task)}>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
content="删除后不可恢复,确定删除该任务?"
|
||||
placement="bottom-right"
|
||||
theme="danger"
|
||||
onConfirm={() => confirmDelete(task)}
|
||||
>
|
||||
<Button className="mat-danger-btn" size="small" variant="text">
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mat-state mat-state--empty">
|
||||
<span className="mat-state-glyph" aria-hidden="true">
|
||||
<Network size={22} />
|
||||
</span>
|
||||
<h3>{tasks.length ? "没有符合条件的任务" : "还没有多智能体定时任务"}</h3>
|
||||
<p>
|
||||
{tasks.length
|
||||
? "换个关键词或筛选条件试试。"
|
||||
: "创建一个编排任务,把多个智能体按并行 / 串行组织起来定时执行。"}
|
||||
</p>
|
||||
{!tasks.length && (
|
||||
<Button theme="primary" onClick={openCreate}>
|
||||
<Plus aria-hidden="true" size={15} />
|
||||
新建编排任务
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mat-footnote">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
前端演示阶段:任务数据保存在本浏览器,执行过程为本地模拟;后端服务接入后自动切换。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
className="mat-detail-drawer"
|
||||
closeBtn
|
||||
destroyOnClose
|
||||
footer={
|
||||
selectedTask ? (
|
||||
<div className="mat-detail-footer">
|
||||
<Button variant="outline" onClick={() => openEdit(selectedTask)}>
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
编辑编排
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void runToggle(selectedTask)}
|
||||
>
|
||||
{selectedTask.enabled ? (
|
||||
<Pause aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<Play aria-hidden="true" size={15} />
|
||||
)}
|
||||
{selectedTask.enabled ? "暂停任务" : "恢复任务"}
|
||||
</Button>
|
||||
<Button
|
||||
className="mat-danger-btn"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
onClick={() => confirmDelete(selectedTask)}
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busyTaskId === selectedTask.id}
|
||||
loading={busyTaskId === selectedTask.id}
|
||||
theme="primary"
|
||||
onClick={() => void runTrigger(selectedTask)}
|
||||
>
|
||||
<Zap aria-hidden="true" size={15} />
|
||||
立即执行
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
header={
|
||||
selectedTask ? (
|
||||
<div className="mat-detail-header">
|
||||
<span className="mat-detail-glyph" aria-hidden="true">
|
||||
<Network size={17} />
|
||||
</span>
|
||||
<div className="mat-detail-heading">
|
||||
<h3>{selectedTask.name}</h3>
|
||||
<p>{describeCron(selectedTask.cron_expr)}</p>
|
||||
</div>
|
||||
{(() => {
|
||||
const badge = taskBadgeOf(selectedTask);
|
||||
return (
|
||||
<Tag theme={badge.theme} variant="light">
|
||||
{badge.label}
|
||||
</Tag>
|
||||
);
|
||||
})()}
|
||||
<Switch
|
||||
value={selectedTask.enabled}
|
||||
onChange={() => void runToggle(selectedTask)}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
placement="right"
|
||||
size="680px"
|
||||
visible={!!selectedTask}
|
||||
onClose={closeTask}
|
||||
>
|
||||
{selectedTask && (
|
||||
<div className="mat-detail">
|
||||
{selectedTask.description && <p className="mat-detail-desc">{selectedTask.description}</p>}
|
||||
|
||||
<div className="mat-detail-grid">
|
||||
<div>
|
||||
<span>下次执行</span>
|
||||
<strong>
|
||||
{selectedTask.enabled ? formatDateTime(selectedTask.nextRunAt) : "已暂停"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近执行</span>
|
||||
<strong>
|
||||
{selectedTask.lastRun
|
||||
? `${formatRelative(selectedTask.lastRun.started_at)} · ${runStatusLabel(selectedTask.lastRun.status)}`
|
||||
: "未执行"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>累计执行</span>
|
||||
<strong>{selectedTask.runs.length} 次</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>智能体 / 阶段</span>
|
||||
<strong>
|
||||
{selectedTask.seats.length} 个 · {selectedStages.length} 阶段
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mat-detail-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>协作编排</h4>
|
||||
{activeRun && (activeRun.status === "running" || activeRun.status === "pending") && (
|
||||
<span className="mat-run-text is-running">
|
||||
阶段 {Math.min(activeRun.stage_index + 1, activeRun.stage_total)} /{" "}
|
||||
{activeRun.stage_total} 执行中…
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="mat-detail-flow">
|
||||
<AgentFlow
|
||||
activeStageIndex={
|
||||
activeRun && activeRun.status !== "succeeded" ? activeRun.stage_index : null
|
||||
}
|
||||
seatStates={activeRun?.seat_states ?? null}
|
||||
seats={selectedTask.seats}
|
||||
showGoals
|
||||
size="sm"
|
||||
stageGoals={selectedTask.stage_goals}
|
||||
stages={selectedStages}
|
||||
/>
|
||||
</div>
|
||||
{activeRun && (
|
||||
<div className="mat-flow-legend">
|
||||
<span>
|
||||
<i className="mat-seat-dot is-done" /> 完成
|
||||
</span>
|
||||
<span>
|
||||
<i className="mat-seat-dot is-running" /> 执行中
|
||||
</span>
|
||||
<span>
|
||||
<i className="mat-seat-dot is-waiting" /> 等待
|
||||
</span>
|
||||
<span>
|
||||
<i className="mat-seat-dot is-failed" /> 失败
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mat-detail-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>任务指令</h4>
|
||||
</header>
|
||||
<p className="mat-detail-prompt">{selectedTask.prompt}</p>
|
||||
{selectedTask.coordinator_prompt && (
|
||||
<>
|
||||
<header className="mat-detail-section-head mat-detail-section-head--sub">
|
||||
<h5>总控提示</h5>
|
||||
</header>
|
||||
<p className="mat-detail-prompt is-sub">{selectedTask.coordinator_prompt}</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mat-detail-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>运行记录</h4>
|
||||
{!!selectedRuns.length && (
|
||||
<small>点击记录可在上方流程图回看各智能体状态</small>
|
||||
)}
|
||||
</header>
|
||||
{selectedRuns.length ? (
|
||||
<div className="mat-run-list">
|
||||
{selectedRuns.map((run) => {
|
||||
const active = activeRun?.id === run.id;
|
||||
const duration = run.finished_at
|
||||
? formatDuration(
|
||||
new Date(run.finished_at).getTime() -
|
||||
new Date(run.started_at).getTime(),
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
className={`mat-run-item${active ? " is-active" : ""}`}
|
||||
key={run.id}
|
||||
onClick={() => setActiveRunId(run.id)}
|
||||
type="button"
|
||||
>
|
||||
<Tag size="small" theme={runTagTheme(run.status)} variant="light">
|
||||
{runStatusLabel(run.status)}
|
||||
</Tag>
|
||||
<span className="mat-run-item-time">
|
||||
{formatDateTime(run.started_at)}
|
||||
</span>
|
||||
<span className="mat-run-item-trigger">
|
||||
{run.trigger === "manual" ? "手动触发" : "按计划执行"}
|
||||
</span>
|
||||
<span className="mat-run-item-duration">
|
||||
{duration ?? `阶段 ${Math.min(run.stage_index + 1, run.stage_total)}/${run.stage_total}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mat-detail-empty">还没有执行记录,可点「立即执行」体验完整流程。</p>
|
||||
)}
|
||||
{activeRun?.error && (
|
||||
<p className="mat-detail-error">
|
||||
<CircleAlert aria-hidden="true" size={14} />
|
||||
{activeRun.error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* 多智能体定时任务 · 独立编辑页。
|
||||
*
|
||||
* 布局:顶部是紧凑表单条(任务名称 + 任务描述),其余表单项(执行频率、任务指令、
|
||||
* 总控提示、启用开关)收进「执行设置」弹窗;主体是智能体编排。
|
||||
* 编排交互与业务链条编辑器一致(复制其 @dnd-kit 模式 + 复用 chain-stages 纯函数):
|
||||
* - 左栏候选池:拖左侧手柄到右侧任意阶段 / 新建阶段落区;点卡片本体 = 追加为新阶段。
|
||||
* - 右栏阶段组:阶段是 droppable,席位是 sortable;席位可跨阶段拖动(变并行)或拖到
|
||||
* 「新建阶段」落区独立成阶段;按钮兜底(并入上/下一阶段、独立成阶段、移除)。
|
||||
* 保存时弹出确认弹窗:任务摘要 + 编排流程图(同阶段并行 · 阶段间串行)+ 图例。
|
||||
*/
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Plus,
|
||||
Save,
|
||||
Search,
|
||||
Settings2,
|
||||
SplitSquareVertical,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button, Dialog, Input, Switch, Textarea } from "tdesign-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import {
|
||||
type ScheduleConfig,
|
||||
buildCron,
|
||||
parseCron,
|
||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
import {
|
||||
addEmptyStage,
|
||||
addSeatAsNewStage,
|
||||
addSeatToStage,
|
||||
countSeats,
|
||||
deleteStage,
|
||||
findStageIndexOfSeat,
|
||||
moveSeatToStage,
|
||||
moveStage,
|
||||
removeSeat,
|
||||
setStageGoal,
|
||||
splitSeatToNewStage,
|
||||
stageGroupsToStageGoals,
|
||||
stageGroupsToStagePayload,
|
||||
type StageGroups,
|
||||
} from "@/roundtable-planning/lib/chain-stages";
|
||||
|
||||
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||
|
||||
import AgentFlow from "./components/AgentFlow";
|
||||
import AgentDetailDrawer, {
|
||||
type AgentDetailTarget,
|
||||
} from "./components/AgentDetailDrawer";
|
||||
import SchedulePicker from "./components/SchedulePicker";
|
||||
import { useMultiAgentTaskActions, useMultiAgentTasks } from "./store";
|
||||
import type { MatSeat, MatTask, MatTaskPayload } from "./types";
|
||||
import {
|
||||
defaultScheduleConfig,
|
||||
describeCron,
|
||||
describeScheduleConfig,
|
||||
expandStages,
|
||||
} from "./utils";
|
||||
|
||||
const POOL_PREFIX = "matpool::";
|
||||
const SEAT_PREFIX = "matseat::";
|
||||
const STAGE_PREFIX = "matstage::";
|
||||
const NEWSTAGE_DROPPABLE_ID = "mat-newstage";
|
||||
|
||||
const MAX_SEATS = 20;
|
||||
const MIN_SEATS = 2;
|
||||
const DEFAULT_TIMEZONE = "Asia/Shanghai";
|
||||
|
||||
type ActiveDrag = { name: string } | null;
|
||||
|
||||
function seatFromAgent(agent: Agent): MatSeat {
|
||||
return {
|
||||
agent_id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFromTask(task: MatTask) {
|
||||
const stages = expandStages(task);
|
||||
const seatById = new Map(task.seats.map((seat) => [seat.agent_id, seat]));
|
||||
return {
|
||||
name: task.name,
|
||||
description: task.description ?? "",
|
||||
schedule: parseCron(task.cron_expr) ?? defaultScheduleConfig(),
|
||||
enabled: task.enabled,
|
||||
prompt: task.prompt,
|
||||
coordinatorPrompt: task.coordinator_prompt ?? "",
|
||||
stageGroups: stages.map((stage, index) => ({
|
||||
seats: stage
|
||||
.map((agentId) => seatById.get(agentId))
|
||||
.filter((seat): seat is MatSeat => !!seat),
|
||||
goal: task.stage_goals?.[index] ?? "",
|
||||
})) as StageGroups,
|
||||
};
|
||||
}
|
||||
|
||||
function groupsToPayloadSeats(groups: StageGroups): MatSeat[] {
|
||||
return groups
|
||||
.filter((group) => group.seats.length > 0)
|
||||
.flatMap((group) => group.seats as MatSeat[]);
|
||||
}
|
||||
|
||||
// ── 左栏:候选智能体卡片(点卡片看详情 / 加号或手柄加入)──────────────────
|
||||
|
||||
function PoolAgentCard({
|
||||
agent,
|
||||
used,
|
||||
onAdd,
|
||||
onShowDetail,
|
||||
}: {
|
||||
agent: Agent;
|
||||
used: boolean;
|
||||
onAdd: () => void;
|
||||
onShowDetail: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: POOL_PREFIX + agent.id,
|
||||
disabled: used,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={`mat-pool-item${used ? " is-used" : ""}${isDragging ? " is-dragging" : ""}`}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
>
|
||||
<button
|
||||
aria-label={used ? "已在编排中" : "拖动加入编排"}
|
||||
className="mat-pool-grip"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
title={used ? "已在编排中" : "拖动到右侧阶段加入(可与其它智能体并行)"}
|
||||
type="button"
|
||||
{...(used ? {} : listeners)}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
<button className="mat-pool-main" onClick={onShowDetail} type="button">
|
||||
<span className="mat-pool-icon" aria-hidden="true">
|
||||
<Bot size={13} />
|
||||
</span>
|
||||
<span className="mat-pool-copy">
|
||||
<span className="mat-pool-item-name">{agent.name}</span>
|
||||
<span className="mat-pool-item-desc">{agent.description || "暂无简介"}</span>
|
||||
</span>
|
||||
</button>
|
||||
{used ? (
|
||||
<span className="mat-pool-used">已加入</span>
|
||||
) : (
|
||||
<button
|
||||
aria-label={`加入 ${agent.name}`}
|
||||
className="mat-pool-join"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAdd();
|
||||
}}
|
||||
title="追加为新阶段"
|
||||
type="button"
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 右栏:单个席位长条(sortable)─────────────────────────────────────────
|
||||
|
||||
function SeatStrip({
|
||||
seat,
|
||||
globalIndex,
|
||||
isFirstStage,
|
||||
isLastStage,
|
||||
onRemove,
|
||||
onMergeUp,
|
||||
onMergeDown,
|
||||
onSplit,
|
||||
onDetail,
|
||||
}: {
|
||||
seat: MatSeat;
|
||||
globalIndex: number;
|
||||
isFirstStage: boolean;
|
||||
isLastStage: boolean;
|
||||
onRemove: () => void;
|
||||
onMergeUp: () => void;
|
||||
onMergeDown: () => void;
|
||||
onSplit: () => void;
|
||||
onDetail: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
||||
return (
|
||||
<div
|
||||
className={`mat-seat-row${isDragging ? " is-dragging" : ""}`}
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
>
|
||||
<button
|
||||
aria-label="拖动调整编排"
|
||||
className="mat-seat-row-grip"
|
||||
title="拖动到其它阶段(并行)/ 排序 / 拖到底部虚线框独立成阶段"
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
<span className="mat-seat-row-no">{globalIndex + 1}</span>
|
||||
<span className="mat-seat-row-icon" aria-hidden="true">
|
||||
<Bot size={13} />
|
||||
</span>
|
||||
<div className="mat-seat-row-copy" title={seat.description}>
|
||||
<span className="mat-seat-row-name">{seat.name}</span>
|
||||
{seat.description && <span className="mat-seat-row-desc">{seat.description}</span>}
|
||||
</div>
|
||||
<span className="mat-seat-row-ops">
|
||||
<button
|
||||
aria-label={`查看 ${seat.name} 详情`}
|
||||
onClick={onDetail}
|
||||
title="查看详情"
|
||||
type="button"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="并入上一阶段(与其并行)"
|
||||
disabled={isFirstStage}
|
||||
onClick={onMergeUp}
|
||||
title="并入上一阶段(与其并行)"
|
||||
type="button"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="独立成阶段"
|
||||
onClick={onSplit}
|
||||
title="独立成阶段"
|
||||
type="button"
|
||||
>
|
||||
<SplitSquareVertical size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="并入下一阶段(与其并行)"
|
||||
disabled={isLastStage}
|
||||
onClick={onMergeDown}
|
||||
title="并入下一阶段(与其并行)"
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button aria-label="移除智能体" onClick={onRemove} title="移除" type="button">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 右栏:阶段容器(droppable + 组内 sortable)────────────────────────────
|
||||
|
||||
function StageBox({
|
||||
group,
|
||||
stageIdx,
|
||||
stageCount,
|
||||
startIndex,
|
||||
onGoalChange,
|
||||
onStageReorder,
|
||||
onStageDelete,
|
||||
onRemoveSeat,
|
||||
onMergeSeat,
|
||||
onSplitSeat,
|
||||
onSeatDetail,
|
||||
}: {
|
||||
group: StageGroups[number];
|
||||
stageIdx: number;
|
||||
stageCount: number;
|
||||
startIndex: number;
|
||||
onGoalChange: (goal: string) => void;
|
||||
onStageReorder: (dir: -1 | 1) => void;
|
||||
onStageDelete: () => void;
|
||||
onRemoveSeat: (agentId: string) => void;
|
||||
onMergeSeat: (agentId: string, targetStage: number) => void;
|
||||
onSplitSeat: (agentId: string) => void;
|
||||
onSeatDetail: (seat: MatSeat) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
|
||||
const seats = group.seats as MatSeat[];
|
||||
const isParallel = seats.length > 1;
|
||||
const isFirstStage = stageIdx === 0;
|
||||
const isLastStage = stageIdx === stageCount - 1;
|
||||
const seatItemIds = seats.map((seat) => SEAT_PREFIX + seat.agent_id);
|
||||
|
||||
return (
|
||||
<article className={`mat-stage-box${isOver ? " is-over" : ""}`}>
|
||||
<header className="mat-stage-box-head">
|
||||
<span className="mat-stage-box-no">阶段 {stageIdx + 1}</span>
|
||||
{isParallel ? (
|
||||
<span className="mat-flow-mode is-parallel">并行 ×{seats.length}</span>
|
||||
) : (
|
||||
<span className="mat-flow-mode is-serial">串行</span>
|
||||
)}
|
||||
<span className="mat-stage-box-ops">
|
||||
<button
|
||||
aria-label="阶段上移"
|
||||
disabled={isFirstStage}
|
||||
onClick={() => onStageReorder(-1)}
|
||||
title="上移"
|
||||
type="button"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="阶段下移"
|
||||
disabled={isLastStage}
|
||||
onClick={() => onStageReorder(1)}
|
||||
title="下移"
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="删除阶段"
|
||||
onClick={onStageDelete}
|
||||
title="删除整个阶段"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
<Textarea
|
||||
autosize
|
||||
className="mat-stage-goal"
|
||||
maxlength={120}
|
||||
placeholder="本阶段目标(可选):本阶段要产出什么、如何衔接上一阶段"
|
||||
value={group.goal ?? ""}
|
||||
onChange={(value) => onGoalChange(String(value ?? ""))}
|
||||
/>
|
||||
<div className="mat-stage-seats" ref={setNodeRef}>
|
||||
{seats.length === 0 ? (
|
||||
<div className="mat-stage-seat-empty">空阶段 —— 把左侧智能体拖到此处,或删除该阶段。</div>
|
||||
) : (
|
||||
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
|
||||
{seats.map((seat, index) => (
|
||||
<SeatStrip
|
||||
globalIndex={startIndex + index}
|
||||
isLastStage={isLastStage}
|
||||
isFirstStage={isFirstStage}
|
||||
key={seat.agent_id}
|
||||
onDetail={() => onSeatDetail(seat)}
|
||||
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
|
||||
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
|
||||
onRemove={() => onRemoveSeat(seat.agent_id)}
|
||||
onSplit={() => onSplitSeat(seat.agent_id)}
|
||||
seat={seat}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 「新建阶段」落区(点击新增 / 拖席位到此独立成最后阶段)────────────────
|
||||
|
||||
function NewStageDropZone({
|
||||
hasEmptyStage,
|
||||
onAddStage,
|
||||
}: {
|
||||
hasEmptyStage: boolean;
|
||||
onAddStage: () => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: NEWSTAGE_DROPPABLE_ID });
|
||||
return (
|
||||
<button
|
||||
className={`mat-stage-dropzone${isOver ? " is-over" : ""}`}
|
||||
disabled={hasEmptyStage}
|
||||
onClick={onAddStage}
|
||||
ref={setNodeRef}
|
||||
title={hasEmptyStage ? "已有空阶段,先填充它" : "新建一个串在最后的阶段"}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={15} />
|
||||
新建阶段(串在最后) · 或把席位拖到此处独立成阶段
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 页面 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TaskEditPage({
|
||||
mode,
|
||||
taskId,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
taskId: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { tasks, isLoading: tasksLoading } = useMultiAgentTasks();
|
||||
const actions = useMultiAgentTaskActions();
|
||||
const [poolSearch, setPoolSearch] = useState("");
|
||||
const { agents, isLoading: agentsLoading } = useAgents({
|
||||
search: poolSearch.trim() || undefined,
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
|
||||
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
||||
const [detailTarget, setDetailTarget] = useState<AgentDetailTarget | null>(null);
|
||||
|
||||
const editingTask = useMemo(
|
||||
() => (mode === "edit" ? (tasks.find((task) => task.id === taskId) ?? null) : null),
|
||||
[mode, taskId, tasks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized || mode === "create") {
|
||||
setInitialized(true);
|
||||
return;
|
||||
}
|
||||
if (!tasksLoading && taskId) {
|
||||
if (editingTask) {
|
||||
const draft = draftFromTask(editingTask);
|
||||
setName(draft.name);
|
||||
setDescription(draft.description);
|
||||
setSchedule(draft.schedule);
|
||||
setEnabled(draft.enabled);
|
||||
setPrompt(draft.prompt);
|
||||
setCoordinatorPrompt(draft.coordinatorPrompt);
|
||||
setStageGroups(draft.stageGroups);
|
||||
}
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [editingTask, initialized, mode, taskId, tasksLoading]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const usedAgentIds = useMemo(
|
||||
() => new Set(stageGroups.flatMap((group) => group.seats.map((seat) => seat.agent_id))),
|
||||
[stageGroups],
|
||||
);
|
||||
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
|
||||
const hasEmptyStage = stageGroups.some((group) => group.seats.length === 0);
|
||||
|
||||
const backToList = useCallback(() => {
|
||||
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/multi-agent-tasks`);
|
||||
}, [navigate]);
|
||||
|
||||
// ── 编排操作(全部复用 chain-stages 纯函数,agent_id 定位)───────────────
|
||||
const addAgentAsStage = useCallback((agent: Agent) => {
|
||||
if (seatCount >= MAX_SEATS) {
|
||||
toast.warning(`编排最多 ${MAX_SEATS} 个智能体`);
|
||||
return;
|
||||
}
|
||||
setStageGroups((prev) => addSeatAsNewStage(prev, seatFromAgent(agent)));
|
||||
}, [seatCount]);
|
||||
|
||||
const onDragStart = useCallback((event: DragStartEvent) => {
|
||||
const id = String(event.active.id);
|
||||
const agentId = id.startsWith(POOL_PREFIX)
|
||||
? id.slice(POOL_PREFIX.length)
|
||||
: id.startsWith(SEAT_PREFIX)
|
||||
? id.slice(SEAT_PREFIX.length)
|
||||
: null;
|
||||
if (!agentId) return;
|
||||
const seatName =
|
||||
agents.find((agent) => agent.id === agentId)?.name ??
|
||||
stageGroups
|
||||
.flatMap((group) => group.seats as MatSeat[])
|
||||
.find((seat) => seat.agent_id === agentId)?.name ??
|
||||
agentId;
|
||||
setActiveDrag({ name: seatName });
|
||||
}, [agents, stageGroups]);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveDrag(null);
|
||||
const activeId = String(event.active.id);
|
||||
const overId = event.over ? String(event.over.id) : null;
|
||||
if (!overId) return;
|
||||
|
||||
let targetStage = -1;
|
||||
let atIndex: number | undefined;
|
||||
if (overId === NEWSTAGE_DROPPABLE_ID) {
|
||||
targetStage = -1;
|
||||
} else if (overId.startsWith(STAGE_PREFIX)) {
|
||||
targetStage = Number(overId.slice(STAGE_PREFIX.length));
|
||||
} else if (overId.startsWith(SEAT_PREFIX)) {
|
||||
const overAgent = overId.slice(SEAT_PREFIX.length);
|
||||
targetStage = findStageIndexOfSeat(stageGroups, overAgent);
|
||||
atIndex = stageGroups[targetStage]?.seats.findIndex(
|
||||
(seat) => seat.agent_id === overAgent,
|
||||
);
|
||||
}
|
||||
|
||||
const toNewStage = overId === NEWSTAGE_DROPPABLE_ID;
|
||||
|
||||
if (activeId.startsWith(POOL_PREFIX)) {
|
||||
const agentId = activeId.slice(POOL_PREFIX.length);
|
||||
if (usedAgentIds.has(agentId)) return;
|
||||
const agent = agents.find((candidate) => candidate.id === agentId);
|
||||
if (!agent || seatCount >= MAX_SEATS) return;
|
||||
const seat = seatFromAgent(agent);
|
||||
setStageGroups((prev) =>
|
||||
toNewStage || targetStage < 0
|
||||
? addSeatAsNewStage(prev, seat)
|
||||
: addSeatToStage(prev, seat, targetStage, atIndex),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeId.startsWith(SEAT_PREFIX)) {
|
||||
const agentId = activeId.slice(SEAT_PREFIX.length);
|
||||
setStageGroups((prev) => {
|
||||
if (toNewStage) {
|
||||
const withEmpty = addEmptyStage(prev);
|
||||
return moveSeatToStage(withEmpty, agentId, withEmpty.length - 1);
|
||||
}
|
||||
if (targetStage < 0) return prev;
|
||||
return moveSeatToStage(prev, agentId, targetStage, atIndex);
|
||||
});
|
||||
}
|
||||
},
|
||||
[agents, seatCount, stageGroups, usedAgentIds],
|
||||
);
|
||||
|
||||
// ── 保存:校验 → 确认弹窗 → 提交 ────────────────────────────────────────
|
||||
const previewGroups = stageGroups.filter((group) => group.seats.length > 0);
|
||||
const previewStages = previewGroups.map((group) =>
|
||||
(group.seats as MatSeat[]).map((seat) => seat.agent_id),
|
||||
);
|
||||
const previewSeats = groupsToPayloadSeats(stageGroups);
|
||||
|
||||
const tryOpenConfirm = () => {
|
||||
if (!name.trim()) {
|
||||
toast.warning("请填写任务名称");
|
||||
return;
|
||||
}
|
||||
if (seatCount < MIN_SEATS) {
|
||||
toast.warning(`编排至少需要 ${MIN_SEATS} 个智能体`);
|
||||
return;
|
||||
}
|
||||
if (hasEmptyStage) {
|
||||
toast.warning("还有空阶段,请填充或删除");
|
||||
return;
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
setSettingsOpen(true);
|
||||
toast.warning("请先在「执行设置」中填写任务指令");
|
||||
return;
|
||||
}
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const submitTask = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// 填了总控提示也强制落显式分层(该提示仅分层模式注入),与业务链条语义一致。
|
||||
const forceExplicit = coordinatorPrompt.trim().length > 0;
|
||||
const payload: MatTaskPayload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
prompt: prompt.trim(),
|
||||
coordinator_prompt: coordinatorPrompt.trim() || undefined,
|
||||
cron_expr: buildCron(schedule),
|
||||
timezone: editingTask?.timezone || DEFAULT_TIMEZONE,
|
||||
enabled,
|
||||
seats: previewSeats,
|
||||
stages: stageGroupsToStagePayload(stageGroups, forceExplicit),
|
||||
stage_goals: stageGroupsToStageGoals(stageGroups, forceExplicit),
|
||||
};
|
||||
if (mode === "edit" && taskId) {
|
||||
await actions.update.mutateAsync({ id: taskId, payload });
|
||||
toast.success("任务已更新");
|
||||
} else {
|
||||
await actions.create.mutateAsync(payload);
|
||||
toast.success("任务已创建");
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
backToList();
|
||||
} catch {
|
||||
toast.error("保存失败,请重试");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === "edit" && !tasksLoading && initialized && !editingTask) {
|
||||
return (
|
||||
<section className="mat-edit-page">
|
||||
<div className="mat-state mat-state--empty">
|
||||
<h3>任务不存在或已被删除</h3>
|
||||
<Button theme="primary" onClick={backToList}>
|
||||
返回任务列表
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
let seatCursor = 0;
|
||||
const settingsSummary = `${describeScheduleConfig(schedule)} · ${enabled ? "已启用" : "已暂停"}`;
|
||||
|
||||
return (
|
||||
<section className="mat-edit-page">
|
||||
<header className="mat-edit-header">
|
||||
<button aria-label="返回任务列表" className="mat-edit-back" onClick={backToList} type="button">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div className="mat-edit-heading">
|
||||
<h1>{mode === "edit" ? `编辑:${editingTask?.name ?? "多智能体定时任务"}` : "新建多智能体定时任务"}</h1>
|
||||
<p>同阶段并行执行 · 阶段之间串行执行;拖拽或点击左侧候选智能体加入编排</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
title="执行频率 / 任务指令 / 总控提示 / 启用"
|
||||
>
|
||||
<Settings2 aria-hidden="true" size={15} />
|
||||
执行设置
|
||||
<span className="mat-settings-summary">{settingsSummary}</span>
|
||||
</Button>
|
||||
<Button theme="primary" onClick={tryOpenConfirm}>
|
||||
<Save aria-hidden="true" size={15} />
|
||||
保存任务
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="mat-edit-formbar">
|
||||
<label className="mat-formbar-field">
|
||||
<span className="mat-formbar-label">
|
||||
任务名称<i>*</i>
|
||||
</span>
|
||||
<Input
|
||||
clearable
|
||||
maxlength={40}
|
||||
placeholder="例如:每日舆情早报"
|
||||
value={name}
|
||||
onChange={(value) => setName(String(value ?? ""))}
|
||||
/>
|
||||
</label>
|
||||
<label className="mat-formbar-field mat-formbar-field--wide">
|
||||
<span className="mat-formbar-label">任务描述</span>
|
||||
<Input
|
||||
clearable
|
||||
maxlength={200}
|
||||
placeholder="一句话说明这个编排任务要达成什么(可选)"
|
||||
value={description}
|
||||
onChange={(value) => setDescription(String(value ?? ""))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mat-edit-formbar mat-edit-formbar--area">
|
||||
<span className="mat-formbar-label">总控提示</span>
|
||||
<Textarea
|
||||
autosize={{ minRows: 1, maxRows: 4 }}
|
||||
className="mat-formbar-textarea"
|
||||
maxlength={1000}
|
||||
placeholder="写给总控(协调者)的编排要求,例如各阶段如何衔接、产出格式约束等(可选)"
|
||||
value={coordinatorPrompt}
|
||||
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mat-edit-body">
|
||||
<section className="mat-edit-card">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<div className="mat-edit-orchestra">
|
||||
<aside className="mat-pool">
|
||||
<header className="mat-pool-head">
|
||||
<span>候选智能体</span>
|
||||
<em>
|
||||
{seatCount}/{MAX_SEATS} 已编排 · {agents.length} 个可选
|
||||
</em>
|
||||
</header>
|
||||
<div className="mat-pool-search">
|
||||
<Search aria-hidden="true" size={15} />
|
||||
<input
|
||||
onChange={(event) => setPoolSearch(event.target.value)}
|
||||
placeholder="搜索智能体"
|
||||
value={poolSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className="mat-pool-list">
|
||||
{agentsLoading ? (
|
||||
<p className="mat-pool-tip">正在加载智能体…</p>
|
||||
) : agents.length ? (
|
||||
agents.map((agent) => (
|
||||
<PoolAgentCard
|
||||
agent={agent}
|
||||
key={agent.id}
|
||||
onAdd={() => addAgentAsStage(agent)}
|
||||
onShowDetail={() =>
|
||||
setDetailTarget({
|
||||
agentId: agent.id,
|
||||
fallbackName: agent.name,
|
||||
fallbackDescription: agent.description ?? undefined,
|
||||
})
|
||||
}
|
||||
used={usedAgentIds.has(agent.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="mat-pool-tip">没有匹配的智能体</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="mat-stage-editor">
|
||||
<div className="mat-stage-list">
|
||||
{stageGroups.map((group, stageIdx) => {
|
||||
const startIndex = seatCursor;
|
||||
seatCursor += (group.seats as MatSeat[]).length;
|
||||
return (
|
||||
<StageBox
|
||||
group={group}
|
||||
key={`stage-box-${stageIdx}`}
|
||||
onGoalChange={(goal) =>
|
||||
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal))
|
||||
}
|
||||
onMergeSeat={(agentId, target) =>
|
||||
setStageGroups((prev) => moveSeatToStage(prev, agentId, target))
|
||||
}
|
||||
onRemoveSeat={(agentId) =>
|
||||
setStageGroups((prev) => removeSeat(prev, agentId))
|
||||
}
|
||||
onSeatDetail={(seat) =>
|
||||
setDetailTarget({
|
||||
agentId: seat.agent_id,
|
||||
fallbackName: seat.name,
|
||||
fallbackDescription: seat.description,
|
||||
})
|
||||
}
|
||||
onSplitSeat={(agentId) =>
|
||||
setStageGroups((prev) => splitSeatToNewStage(prev, agentId))
|
||||
}
|
||||
onStageDelete={() =>
|
||||
setStageGroups((prev) => deleteStage(prev, stageIdx))
|
||||
}
|
||||
onStageReorder={(dir) =>
|
||||
setStageGroups((prev) => moveStage(prev, stageIdx, dir))
|
||||
}
|
||||
stageCount={stageGroups.length}
|
||||
stageIdx={stageIdx}
|
||||
startIndex={startIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<NewStageDropZone
|
||||
hasEmptyStage={hasEmptyStage}
|
||||
onAddStage={() => setStageGroups((prev) => addEmptyStage(prev))}
|
||||
/>
|
||||
{!stageGroups.length && (
|
||||
<p className="mat-stage-empty">
|
||||
从左侧点击或拖拽候选智能体加入编排,至少 {MIN_SEATS} 个。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeDrag ? (
|
||||
<div className="mat-drag-overlay">
|
||||
<GripVertical size={14} />
|
||||
{activeDrag.name}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
className="mat-settings-dialog"
|
||||
closeBtn
|
||||
closeOnOverlayClick={false}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="mat-confirm-footer">
|
||||
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button theme="primary" onClick={() => setSettingsOpen(false)}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
header="执行设置"
|
||||
placement="top"
|
||||
visible={settingsOpen}
|
||||
width={620}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
>
|
||||
<div className="mat-settings">
|
||||
<div className="mat-field">
|
||||
<span className="mat-field-label">执行频率</span>
|
||||
<SchedulePicker value={schedule} onChange={setSchedule} />
|
||||
</div>
|
||||
<label className="mat-field">
|
||||
<span className="mat-field-label">
|
||||
任务指令<i>*</i>
|
||||
</span>
|
||||
<Textarea
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
maxlength={2000}
|
||||
placeholder="描述每次定时执行要让智能体编队完成的事情,例如:汇总过去 24 小时重点舆情并生成早报…"
|
||||
value={prompt}
|
||||
onChange={(value) => setPrompt(String(value ?? ""))}
|
||||
/>
|
||||
</label>
|
||||
<div className="mat-field mat-field--switch">
|
||||
<span className="mat-field-label">保存后立即启用</span>
|
||||
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
className="mat-confirm-dialog"
|
||||
closeBtn
|
||||
closeOnOverlayClick={false}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="mat-confirm-footer">
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
再改改
|
||||
</Button>
|
||||
<Button loading={saving} theme="primary" onClick={() => void submitTask()}>
|
||||
确认保存
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
header="确认保存任务"
|
||||
placement="top"
|
||||
visible={confirmOpen}
|
||||
width={720}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
>
|
||||
<div className="mat-confirm">
|
||||
<div className="mat-confirm-grid">
|
||||
<div>
|
||||
<span>任务名称</span>
|
||||
<strong>{name.trim() || "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>执行频率</span>
|
||||
<strong>{describeCron(buildCron(schedule))}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>启用状态</span>
|
||||
<strong>{enabled ? "启用" : "暂停"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>智能体 / 阶段</span>
|
||||
<strong>
|
||||
{previewSeats.length} 个智能体 · {previewStages.length} 个阶段
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mat-confirm-flow">
|
||||
<AgentFlow
|
||||
seats={previewSeats}
|
||||
showGoals
|
||||
size="sm"
|
||||
stageGoals={previewGroups.map((group) => (group.goal ?? "").trim() || null)}
|
||||
stages={previewStages}
|
||||
/>
|
||||
</div>
|
||||
<p className="mat-confirm-legend">
|
||||
<span>
|
||||
<i className="mat-flow-mode is-parallel" style={{ cursor: "default" }} /> 并行:同阶段智能体同时执行
|
||||
</span>
|
||||
<span>
|
||||
<i className="mat-flow-mode is-serial" style={{ cursor: "default" }} /> 串行:阶段之间依次执行
|
||||
</span>
|
||||
</p>
|
||||
{prompt.trim() && (
|
||||
<div className="mat-confirm-prompt">
|
||||
<span>任务指令</span>
|
||||
<p>{prompt.trim()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<AgentDetailDrawer
|
||||
target={detailTarget}
|
||||
onClose={() => setDetailTarget(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 智能体详情抽屉:候选池卡片 / 编排席位条「查看详情」共用。
|
||||
* 后端查不到(本地演示席位)时回退到编排里保存的名称/描述快照。
|
||||
*/
|
||||
|
||||
import { Bot } from "lucide-react";
|
||||
import { Drawer, Tag } from "tdesign-react";
|
||||
|
||||
import { useAgent } from "@/core/agents";
|
||||
|
||||
export interface AgentDetailTarget {
|
||||
agentId: string;
|
||||
fallbackName?: string;
|
||||
fallbackDescription?: string;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function AgentDetailDrawer({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: AgentDetailTarget | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { agent, isLoading } = useAgent(target?.agentId ?? null, {
|
||||
enabled: !!target,
|
||||
});
|
||||
|
||||
const name = agent?.name ?? target?.fallbackName ?? target?.agentId ?? "";
|
||||
const description =
|
||||
agent?.description ?? target?.fallbackDescription ?? agent?.description ?? "";
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
className="mat-agent-drawer"
|
||||
closeBtn
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
header={
|
||||
<div className="mat-agent-header">
|
||||
<span className="mat-agent-glyph" aria-hidden="true">
|
||||
<Bot size={17} />
|
||||
</span>
|
||||
<div className="mat-agent-heading">
|
||||
<h3>{name}</h3>
|
||||
<p>智能体详情</p>
|
||||
</div>
|
||||
{agent && (
|
||||
<Tag size="small" theme={agent.builtin ? "default" : "primary"} variant="light">
|
||||
{agent.builtin ? "内置" : agent.published ? "已发布" : "私有"}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
size="460px"
|
||||
visible={!!target}
|
||||
onClose={onClose}
|
||||
>
|
||||
{target && (
|
||||
<div className="mat-agent">
|
||||
{isLoading ? (
|
||||
<p className="mat-agent-loading">正在加载智能体详情…</p>
|
||||
) : agent ? (
|
||||
<>
|
||||
<div className="mat-detail-grid">
|
||||
<div>
|
||||
<span>创建者</span>
|
||||
<strong>
|
||||
{agent.owner_username
|
||||
? agent.owner_username
|
||||
: agent.builtin
|
||||
? "内置智能体"
|
||||
: "—"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>执行模型</span>
|
||||
<strong>{agent.model || "默认模型"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>创建时间</span>
|
||||
<strong>{formatDateTime(agent.created_at)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>技能数</span>
|
||||
<strong>{agent.skills?.length ?? 0} 个</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<section className="mat-agent-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>简介</h4>
|
||||
</header>
|
||||
<p className="mat-agent-desc">{agent.description}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!!agent.tags?.length && (
|
||||
<section className="mat-agent-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>标签</h4>
|
||||
</header>
|
||||
<div className="mat-agent-tags">
|
||||
{agent.tags.map((tag) => (
|
||||
<Tag key={tag.id} size="small" variant="light">
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!!agent.skills?.length && (
|
||||
<section className="mat-agent-section">
|
||||
<header className="mat-detail-section-head">
|
||||
<h4>已配置技能</h4>
|
||||
</header>
|
||||
<div className="mat-agent-tags">
|
||||
{agent.skills.map((skill) => (
|
||||
<Tag key={skill} size="small" theme="primary" variant="light">
|
||||
{skill}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mat-agent-local">
|
||||
<div className="mat-detail-grid">
|
||||
<div>
|
||||
<span>创建者</span>
|
||||
<strong>本地演示</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>数据来源</span>
|
||||
<strong>编排内置快照</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mat-agent-desc">
|
||||
{description || "该席位来自演示种子数据,未对应后端智能体目录中的记录。接入后端后,编排将引用真实智能体。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Bot, MoveRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import type { MatSeat, MatSeatRunState } from "../types";
|
||||
import { seatColorIndex } from "../utils";
|
||||
|
||||
export interface AgentFlowProps {
|
||||
stages: string[][];
|
||||
seats: MatSeat[];
|
||||
stageGoals?: (string | null)[] | null;
|
||||
/** 提供时在席位上渲染执行状态点(运行图例)。 */
|
||||
seatStates?: Record<string, MatSeatRunState> | null;
|
||||
/** 当前执行到的阶段下标;提供时高亮进度。 */
|
||||
activeStageIndex?: number | null;
|
||||
size?: "sm" | "md";
|
||||
showGoals?: boolean;
|
||||
}
|
||||
|
||||
const STATE_LABEL: Record<MatSeatRunState, string> = {
|
||||
waiting: "等待",
|
||||
running: "执行中",
|
||||
done: "完成",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
export default function AgentFlow({
|
||||
stages,
|
||||
seats,
|
||||
stageGoals,
|
||||
seatStates,
|
||||
activeStageIndex,
|
||||
size = "md",
|
||||
showGoals = false,
|
||||
}: AgentFlowProps) {
|
||||
const seatById = new Map(seats.map((seat) => [seat.agent_id, seat]));
|
||||
|
||||
// 只有一个阶段时不画阶段框:席位直接横向排列。
|
||||
if (stages.length === 1) {
|
||||
return (
|
||||
<div className={`mat-flow mat-flow--single mat-flow--${size}`}>
|
||||
{stages[0]!.map((agentId) => {
|
||||
const seat = seatById.get(agentId);
|
||||
const state = seatStates?.[agentId] ?? null;
|
||||
return (
|
||||
<div
|
||||
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
|
||||
state ? ` is-${state}` : ""
|
||||
}`}
|
||||
key={agentId}
|
||||
title={
|
||||
seat?.description
|
||||
? `${seat.name}:${seat.description}`
|
||||
: (seat?.name ?? agentId)
|
||||
}
|
||||
>
|
||||
<span className="mat-seat-avatar">
|
||||
<Bot size={size === "sm" ? 11 : 12} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
|
||||
{state && (
|
||||
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mat-flow mat-flow--${size}`}>
|
||||
{stages.map((stage, stageIndex) => {
|
||||
const parallel = stage.length > 1;
|
||||
const isActive = typeof activeStageIndex === "number" && activeStageIndex === stageIndex;
|
||||
const isPast =
|
||||
typeof activeStageIndex === "number" && activeStageIndex > stageIndex;
|
||||
const goal = showGoals ? stageGoals?.[stageIndex] ?? null : null;
|
||||
return (
|
||||
<Fragment key={`stage-${stageIndex}`}>
|
||||
{stageIndex > 0 && (
|
||||
<div className="mat-flow-arrow" aria-hidden="true">
|
||||
<MoveRight size={size === "sm" ? 14 : 16} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
className={[
|
||||
"mat-flow-stage",
|
||||
isActive ? "is-active" : "",
|
||||
isPast ? "is-done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<header className="mat-flow-stage-head">
|
||||
<span className="mat-flow-stage-no">阶段 {stageIndex + 1}</span>
|
||||
{parallel ? (
|
||||
<span className="mat-flow-mode is-parallel">并行 ×{stage.length}</span>
|
||||
) : (
|
||||
<span className="mat-flow-mode is-serial">串行</span>
|
||||
)}
|
||||
</header>
|
||||
<div className="mat-flow-seats">
|
||||
{stage.map((agentId) => {
|
||||
const seat = seatById.get(agentId);
|
||||
const state = seatStates?.[agentId] ?? null;
|
||||
return (
|
||||
<div
|
||||
className={`mat-seat mat-seat--c${seatColorIndex(agentId)}${
|
||||
state ? ` is-${state}` : ""
|
||||
}`}
|
||||
key={agentId}
|
||||
title={
|
||||
seat?.description
|
||||
? `${seat.name}:${seat.description}`
|
||||
: (seat?.name ?? agentId)
|
||||
}
|
||||
>
|
||||
<span className="mat-seat-avatar">
|
||||
<Bot size={size === "sm" ? 11 : 12} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="mat-seat-name">{seat?.name ?? agentId}</span>
|
||||
{state && (
|
||||
<span className={`mat-seat-dot is-${state}`} title={STATE_LABEL[state]} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{goal && <p className="mat-flow-goal">{goal}</p>}
|
||||
</section>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Select } from "tdesign-react";
|
||||
|
||||
import type { ScheduleConfig, ScheduleFreq } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
|
||||
import { describeScheduleConfig } from "../utils";
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
const HOURS = Array.from({ length: 24 }, (_, index) => index);
|
||||
const MINUTES = [0, 15, 30, 45];
|
||||
const DATES = Array.from({ length: 31 }, (_, index) => index + 1);
|
||||
|
||||
const FREQ_OPTIONS: Array<{ label: string; value: ScheduleFreq }> = [
|
||||
{ label: "每小时", value: "hourly" },
|
||||
{ label: "每天", value: "daily" },
|
||||
{ label: "每周", value: "weekly" },
|
||||
{ label: "每月", value: "monthly" },
|
||||
];
|
||||
|
||||
export default function SchedulePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ScheduleConfig;
|
||||
onChange: (config: ScheduleConfig) => void;
|
||||
}) {
|
||||
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
|
||||
const toNumber = (input: unknown) => Number(input as string | number);
|
||||
|
||||
return (
|
||||
<div className="mat-schedule-picker">
|
||||
<div className="mat-schedule-row">
|
||||
<Select
|
||||
options={FREQ_OPTIONS}
|
||||
value={value.freq}
|
||||
onChange={(next) => set({ freq: next as ScheduleFreq })}
|
||||
/>
|
||||
{value.freq === "hourly" && (
|
||||
<Select
|
||||
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
|
||||
value={value.minute}
|
||||
onChange={(next) => set({ minute: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
{value.freq === "weekly" && (
|
||||
<Select
|
||||
options={WEEKDAYS.map((label, index) => ({ label, value: index }))}
|
||||
value={value.weekday}
|
||||
onChange={(next) => set({ weekday: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
{value.freq === "monthly" && (
|
||||
<Select
|
||||
options={DATES.map((date) => ({ label: `第 ${date} 日`, value: date }))}
|
||||
value={value.date}
|
||||
onChange={(next) => set({ date: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
{value.freq !== "hourly" && (
|
||||
<Select
|
||||
options={HOURS.map((hour) => ({ label: `${hour} 时`, value: hour }))}
|
||||
value={value.hour}
|
||||
onChange={(next) => set({ hour: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
{value.freq !== "hourly" && (
|
||||
<Select
|
||||
options={MINUTES.map((minute) => ({ label: `${minute} 分`, value: minute }))}
|
||||
value={value.minute}
|
||||
onChange={(next) => set({ minute: toNumber(next) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mat-schedule-preview">{describeScheduleConfig(value)} 自动执行</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 多智能体定时任务的本地 mock 存储层。
|
||||
*
|
||||
* 后端接口尚未实现,先用 localStorage 持久化任务与运行记录:
|
||||
* - 「立即执行」写入一条运行记录,状态由 started_at + duration_ms 在
|
||||
* 读取时按当前时间推导(排队 → 按阶段推进 → 成功/失败),配合
|
||||
* React Query 的轮询即可获得无后端的“实时”执行动画。
|
||||
* - 接口就绪后,把本文件的 CRUD 换成 HTTP 调用即可,页面无需改动。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
import { expandStages, nextCronDate } from "./utils";
|
||||
import type {
|
||||
MatRunRecord,
|
||||
MatRunStatus,
|
||||
MatRunView,
|
||||
MatSeatRunState,
|
||||
MatTask,
|
||||
MatTaskPayload,
|
||||
MatTaskView,
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "deerflow.official.multi-agent-tasks.v1";
|
||||
|
||||
interface MatStore {
|
||||
tasks: MatTask[];
|
||||
runs: MatRunRecord[];
|
||||
}
|
||||
|
||||
const PENDING_MS = 1_500;
|
||||
const STAGE_MS = 4_000;
|
||||
|
||||
function readStore(): MatStore {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<MatStore>;
|
||||
if (Array.isArray(parsed.tasks)) {
|
||||
return { tasks: parsed.tasks as MatTask[], runs: parsed.runs ?? [] };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 解析失败按首次访问处理,重写种子数据。
|
||||
}
|
||||
const seeded = seedStore();
|
||||
writeStore(seeded);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function writeStore(store: MatStore) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// 存储被禁用时静默降级为内存态。
|
||||
}
|
||||
}
|
||||
|
||||
function mutateStore(mutator: (store: MatStore) => MatStore): MatStore {
|
||||
const next = mutator(readStore());
|
||||
writeStore(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function hoursAgo(hours: number): string {
|
||||
return new Date(Date.now() - hours * 3_600_000).toISOString();
|
||||
}
|
||||
|
||||
function seedStore(): MatStore {
|
||||
const seats = {
|
||||
collector: { agent_id: "mat-seed-collector", name: "全网舆情采集", description: "多源检索并汇总最新舆情线索" },
|
||||
monitor: { agent_id: "mat-seed-monitor", name: "舆情监测雷达", description: "盯住重点信源并输出监测明细" },
|
||||
analyst: { agent_id: "mat-seed-analyst", name: "舆情研判分析", description: "对采集结果做情感与趋势研判" },
|
||||
writer: { agent_id: "mat-seed-writer", name: "报告撰写助手", description: "把各阶段产出整理成结构化报告" },
|
||||
verifier: { agent_id: "mat-seed-verifier", name: "数据核查员", description: "交叉校验数据与结论的可靠性" },
|
||||
rival: { agent_id: "mat-seed-rival", name: "竞品情报员", description: "跟踪竞品动态与公开信息" },
|
||||
researcher: { agent_id: "mat-seed-researcher", name: "行业研究员", description: "梳理行业政策与市场变化" },
|
||||
forecaster: { agent_id: "mat-seed-forecaster", name: "趋势预测专家", description: "基于数据推演后续走势" },
|
||||
reviewer: { agent_id: "mat-seed-reviewer", name: "内容审核官", description: "审核报告口径与合规风险" },
|
||||
} satisfies Record<string, MatTask["seats"][number]>;
|
||||
|
||||
const tasks: MatTask[] = [
|
||||
{
|
||||
id: "mat-task-daily",
|
||||
name: "每日舆情早报",
|
||||
description: "每天早上自动汇总过去 24 小时重点舆情,并行采集 + 研判 + 成稿。",
|
||||
prompt:
|
||||
"汇总过去 24 小时与本公司相关的重点舆情:先并行完成多源采集与监测,再研判情感与趋势,最后汇总为一份不超过 1500 字的舆情早报,附风险提示。",
|
||||
coordinator_prompt: "采集阶段允许两个智能体各自独立检索,不做信息合并;研判阶段需引用采集阶段的原始线索。",
|
||||
cron_expr: "0 9 * * *",
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: true,
|
||||
seats: [seats.collector, seats.monitor, seats.analyst, seats.writer],
|
||||
stages: [
|
||||
[seats.collector.agent_id, seats.monitor.agent_id],
|
||||
[seats.analyst.agent_id],
|
||||
[seats.writer.agent_id],
|
||||
],
|
||||
stage_goals: ["并行完成全网采集与重点信源监测", "基于原始线索完成情感与趋势研判", "汇总为可直发的舆情早报"],
|
||||
created_at: hoursAgo(24 * 18),
|
||||
updated_at: hoursAgo(26),
|
||||
},
|
||||
{
|
||||
id: "mat-task-weekly",
|
||||
name: "周度竞品情报速览",
|
||||
description: "每周一串行走查竞品动态,核查后输出速览简报。",
|
||||
prompt: "跟踪 5 家核心竞品过去一周的公开动态(产品、市场、人事),核查数据可信度后输出竞品速览简报。",
|
||||
cron_expr: "30 8 * * 1",
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: false,
|
||||
seats: [seats.rival, seats.verifier, seats.writer],
|
||||
stages: null,
|
||||
stage_goals: null,
|
||||
created_at: hoursAgo(24 * 40),
|
||||
updated_at: hoursAgo(24 * 3),
|
||||
},
|
||||
{
|
||||
id: "mat-task-monthly",
|
||||
name: "月度行业洞察报告",
|
||||
description: "每月并行完成行业研究、趋势预测与数据核查,审核后成稿。",
|
||||
prompt:
|
||||
"围绕所在行业输出月度洞察:行业研究员、趋势预测专家与数据核查员并行推进,随后由内容审核官把关,最终产出月度洞察报告。",
|
||||
cron_expr: "0 10 1 * *",
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: true,
|
||||
seats: [seats.researcher, seats.forecaster, seats.verifier, seats.reviewer, seats.writer],
|
||||
stages: [
|
||||
[seats.researcher.agent_id, seats.forecaster.agent_id, seats.verifier.agent_id],
|
||||
[seats.reviewer.agent_id],
|
||||
[seats.writer.agent_id],
|
||||
],
|
||||
stage_goals: ["并行完成行业研究、趋势预测与数据核查", "审核结论口径与合规风险", "整合为月度洞察报告"],
|
||||
created_at: hoursAgo(24 * 9),
|
||||
updated_at: hoursAgo(24 * 9),
|
||||
},
|
||||
];
|
||||
|
||||
const runs: MatRunRecord[] = [
|
||||
runSeed("mat-task-daily", 2.2, 3, null),
|
||||
runSeed("mat-task-daily", 26, 3, null),
|
||||
runSeed("mat-task-daily", 50, 3, null),
|
||||
runSeed("mat-task-weekly", 24 * 3, 3, 2),
|
||||
runSeed("mat-task-weekly", 24 * 10, 3, null),
|
||||
];
|
||||
return { tasks, runs };
|
||||
}
|
||||
|
||||
function runSeed(
|
||||
taskId: string,
|
||||
hoursAgoValue: number,
|
||||
stageTotal: number,
|
||||
failStage: number | null,
|
||||
): MatRunRecord {
|
||||
const started = hoursAgo(hoursAgoValue);
|
||||
return {
|
||||
id: uuid() as string,
|
||||
task_id: taskId,
|
||||
trigger: "schedule",
|
||||
scheduled_for: started,
|
||||
started_at: started,
|
||||
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
|
||||
fail_stage: failStage,
|
||||
};
|
||||
}
|
||||
|
||||
/** 由记录 + 当前时间推导运行视图:排队 → 按阶段推进 → 成功/失败。 */
|
||||
function resolveRunView(run: MatRunRecord, stages: string[][], now = Date.now()): MatRunView {
|
||||
const startedAt = new Date(run.started_at).getTime();
|
||||
const elapsed = now - startedAt;
|
||||
const stageTotal = Math.max(1, stages.length);
|
||||
const duration = run.duration_ms || PENDING_MS + stageTotal * STAGE_MS;
|
||||
|
||||
const buildStates = (
|
||||
status: MatRunStatus,
|
||||
activeStage: number,
|
||||
): Record<string, MatSeatRunState> => {
|
||||
const states: Record<string, MatSeatRunState> = {};
|
||||
stages.forEach((stage, index) => {
|
||||
const seatState: MatSeatRunState =
|
||||
status === "succeeded"
|
||||
? "done"
|
||||
: index < activeStage
|
||||
? "done"
|
||||
: index === activeStage
|
||||
? status === "failed"
|
||||
? "failed"
|
||||
: "running"
|
||||
: "waiting";
|
||||
stage.forEach((agentId) => {
|
||||
states[agentId] = seatState;
|
||||
});
|
||||
});
|
||||
return states;
|
||||
};
|
||||
|
||||
if (elapsed < PENDING_MS) {
|
||||
return {
|
||||
...run,
|
||||
finished_at: null,
|
||||
status: "pending",
|
||||
stage_index: 0,
|
||||
stage_total: stageTotal,
|
||||
seat_states: buildStates("pending", -1),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const failStage = typeof run.fail_stage === "number" ? run.fail_stage : null;
|
||||
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
|
||||
const activeStage = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
|
||||
|
||||
if (failStage !== null && activeStage >= failStage) {
|
||||
const finishedAt = new Date(startedAt + PENDING_MS + (failStage + 1) * STAGE_MS).toISOString();
|
||||
return {
|
||||
...run,
|
||||
finished_at: finishedAt,
|
||||
status: "failed",
|
||||
stage_index: failStage,
|
||||
stage_total: stageTotal,
|
||||
seat_states: buildStates("failed", failStage),
|
||||
error: `阶段 ${failStage + 1} 执行失败:模拟的执行异常(前端演示数据)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (elapsed >= duration) {
|
||||
return {
|
||||
...run,
|
||||
finished_at: new Date(startedAt + duration).toISOString(),
|
||||
status: "succeeded",
|
||||
stage_index: stageTotal - 1,
|
||||
stage_total: stageTotal,
|
||||
seat_states: buildStates("succeeded", stageTotal),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
finished_at: null,
|
||||
status: "running",
|
||||
stage_index: activeStage,
|
||||
stage_total: stageTotal,
|
||||
seat_states: buildStates("running", activeStage),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskViews(now = Date.now()): MatTaskView[] {
|
||||
const store = readStore();
|
||||
return store.tasks.map((task) => {
|
||||
const stages = expandStages(task);
|
||||
const runs = store.runs
|
||||
.filter((run) => run.task_id === task.id)
|
||||
.map((run) => resolveRunView(run, stages, now))
|
||||
.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
|
||||
const next = task.enabled ? nextCronDate(task.cron_expr, new Date(now)) : null;
|
||||
return {
|
||||
...task,
|
||||
runs,
|
||||
lastRun: runs[0] ?? null,
|
||||
nextRunAt: next ? next.toISOString() : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function anyRunLive(tasks: MatTaskView[] | undefined): boolean {
|
||||
return !!tasks?.some((task) =>
|
||||
task.runs.some((run) => run.status === "pending" || run.status === "running"),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMultiAgentTasks() {
|
||||
const query = useQuery({
|
||||
queryKey: ["mat", "tasks"],
|
||||
queryFn: () => buildTaskViews(),
|
||||
refetchInterval: (q) => (anyRunLive(q.state.data as MatTaskView[] | undefined) ? 1_800 : false),
|
||||
});
|
||||
return { tasks: query.data ?? [], isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useMultiAgentTaskActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["mat", "tasks"] });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (payload: MatTaskPayload) => {
|
||||
const now = new Date().toISOString();
|
||||
const task: MatTask = { ...payload, id: uuid() as string, created_at: now, updated_at: now };
|
||||
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
|
||||
return task;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: MatTaskPayload }) => {
|
||||
mutateStore((store) => ({
|
||||
...store,
|
||||
tasks: store.tasks.map((task) =>
|
||||
task.id === id ? { ...task, ...payload, updated_at: new Date().toISOString() } : task,
|
||||
),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
mutateStore((store) => ({
|
||||
tasks: store.tasks.filter((task) => task.id !== id),
|
||||
runs: store.runs.filter((run) => run.task_id !== id),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const setEnabled = useMutation({
|
||||
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
||||
mutateStore((store) => ({
|
||||
...store,
|
||||
tasks: store.tasks.map((task) =>
|
||||
task.id === id ? { ...task, enabled, updated_at: new Date().toISOString() } : task,
|
||||
),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const trigger = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const store = readStore();
|
||||
const task = store.tasks.find((item) => item.id === id);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
const stageTotal = expandStages(task).length;
|
||||
const now = new Date().toISOString();
|
||||
const run: MatRunRecord = {
|
||||
id: uuid() as string,
|
||||
task_id: id,
|
||||
trigger: "manual",
|
||||
scheduled_for: now,
|
||||
started_at: now,
|
||||
duration_ms: PENDING_MS + stageTotal * STAGE_MS,
|
||||
fail_stage: null,
|
||||
};
|
||||
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
|
||||
return run;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const removeRun = useMutation({
|
||||
mutationFn: async (runId: string) => {
|
||||
mutateStore((store) => ({ ...store, runs: store.runs.filter((run) => run.id !== runId) }));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, setEnabled, trigger, removeRun };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 多智能体定时任务 —— 前端先行数据模型。
|
||||
*
|
||||
* 编排语义与业务链条保持一致:同一阶段(stage)内的多个智能体并行执行,
|
||||
* 阶段之间串行执行;`stages: null` 表示纯串行线性链(每个智能体一层)。
|
||||
* 当前后端尚未提供接口,数据先落 localStorage,接口就绪后只需替换 store 层。
|
||||
*/
|
||||
|
||||
export type MatRunStatus = "pending" | "running" | "succeeded" | "failed";
|
||||
export type MatSeatRunState = "waiting" | "running" | "done" | "failed";
|
||||
export type MatRunTrigger = "manual" | "schedule";
|
||||
|
||||
/** 编排中的一个智能体席位(落库时保存名称快照,展示优先用快照)。 */
|
||||
export interface MatSeat {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MatTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** 任务指令:本次定时执行要让智能体编队完成什么。 */
|
||||
prompt: string;
|
||||
/** 总控(协调者)提示词:告诉总控如何编排各阶段的产出。 */
|
||||
coordinator_prompt?: string;
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
seats: MatSeat[];
|
||||
/** `stages[i]` 内的智能体并行,i 递增串行;null = 线性串行。 */
|
||||
stages: string[][] | null;
|
||||
/** 与 stages 同下标对齐的每阶段目标。 */
|
||||
stage_goals?: (string | null)[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 落库的运行记录;状态由 started_at + duration_ms 在读取时推导。 */
|
||||
export interface MatRunRecord {
|
||||
id: string;
|
||||
task_id: string;
|
||||
trigger: MatRunTrigger;
|
||||
scheduled_for: string;
|
||||
started_at: string;
|
||||
duration_ms: number;
|
||||
/** 模拟在该阶段(下标)失败;null = 成功。 */
|
||||
fail_stage?: number | null;
|
||||
}
|
||||
|
||||
/** 读取时推导出的运行视图。 */
|
||||
export interface MatRunView {
|
||||
id: string;
|
||||
task_id: string;
|
||||
trigger: MatRunTrigger;
|
||||
scheduled_for: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
status: MatRunStatus;
|
||||
/** 当前执行到的阶段下标(从 0 开始)。 */
|
||||
stage_index: number;
|
||||
stage_total: number;
|
||||
seat_states: Record<string, MatSeatRunState>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface MatTaskView extends MatTask {
|
||||
runs: MatRunView[];
|
||||
lastRun: MatRunView | null;
|
||||
nextRunAt: string | null;
|
||||
}
|
||||
|
||||
/** 编辑器内部使用的阶段草稿。 */
|
||||
export interface MatStageGroup {
|
||||
seats: MatSeat[];
|
||||
goal: string;
|
||||
}
|
||||
|
||||
/** 编辑器保存时对外提交的载荷。 */
|
||||
export interface MatTaskPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
prompt: string;
|
||||
coordinator_prompt?: string;
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
seats: MatSeat[];
|
||||
stages: string[][] | null;
|
||||
stage_goals: (string | null)[] | null;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
DEFAULT_SCHEDULE_CONFIG,
|
||||
type ScheduleConfig,
|
||||
parseCron,
|
||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
|
||||
import type { MatSeat, MatTask, MatTaskView } from "./types";
|
||||
|
||||
const WEEKDAY_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
|
||||
function pad2(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** 把任务里的 stages 语义展开成显式阶段列表;null = 每个席位独立一层。 */
|
||||
export function expandStages(task: Pick<MatTask, "seats" | "stages">): string[][] {
|
||||
const known = new Set(task.seats.map((seat) => seat.agent_id));
|
||||
if (!task.stages?.length) {
|
||||
return task.seats.map((seat) => [seat.agent_id]);
|
||||
}
|
||||
const expanded = task.stages
|
||||
.map((stage) => stage.filter((id) => known.has(id)))
|
||||
.filter((stage) => stage.length > 0);
|
||||
return expanded.length ? expanded : task.seats.map((seat) => [seat.agent_id]);
|
||||
}
|
||||
|
||||
export function describeScheduleConfig(cfg: ScheduleConfig): string {
|
||||
const clock = `${pad2(cfg.hour)}:${pad2(cfg.minute)}`;
|
||||
switch (cfg.freq) {
|
||||
case "hourly":
|
||||
return `每小时 ${cfg.minute} 分`;
|
||||
case "weekly":
|
||||
return `每${WEEKDAY_LABELS[cfg.weekday] ?? "周一"} ${clock}`;
|
||||
case "monthly":
|
||||
return `每月 ${cfg.date} 日 ${clock}`;
|
||||
default:
|
||||
return `每天 ${clock}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 由 cron 反推人类可读的执行频率描述(覆盖本页生成的所有 cron 形态)。 */
|
||||
export function describeCron(cron: string | null | undefined): string {
|
||||
const cfg = parseCron(cron ?? undefined);
|
||||
if (!cfg) return cron || "未设置";
|
||||
return describeScheduleConfig(cfg);
|
||||
}
|
||||
|
||||
/** 计算下一次执行时间;仅支持本页 schedule picker 生成的 5 段 cron 形态。 */
|
||||
export function nextCronDate(cron: string | null | undefined, from = new Date()): Date | null {
|
||||
const cfg = parseCron(cron ?? undefined);
|
||||
if (!cfg) return null;
|
||||
const candidate = new Date(from);
|
||||
candidate.setSeconds(0, 0);
|
||||
switch (cfg.freq) {
|
||||
case "hourly": {
|
||||
candidate.setMinutes(cfg.minute);
|
||||
if (candidate <= from) candidate.setHours(candidate.getHours() + 1);
|
||||
return candidate;
|
||||
}
|
||||
case "daily": {
|
||||
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||
if (candidate <= from) candidate.setDate(candidate.getDate() + 1);
|
||||
return candidate;
|
||||
}
|
||||
case "weekly": {
|
||||
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||
const day = candidate.getDay();
|
||||
let delta = (cfg.weekday - day + 7) % 7;
|
||||
if (delta === 0 && candidate <= from) delta = 7;
|
||||
candidate.setDate(candidate.getDate() + delta);
|
||||
return candidate;
|
||||
}
|
||||
case "monthly": {
|
||||
candidate.setHours(cfg.hour, cfg.minute, 0, 0);
|
||||
candidate.setDate(cfg.date);
|
||||
if (candidate <= from) candidate.setMonth(candidate.getMonth() + 1);
|
||||
return candidate;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatRelative(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
const elapsed = Date.now() - date.getTime();
|
||||
if (elapsed < 60_000) return "刚刚";
|
||||
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} 分钟前`;
|
||||
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`;
|
||||
if (elapsed < 7 * 86_400_000) return `${Math.floor(elapsed / 86_400_000)} 天前`;
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
||||
const seconds = Math.round(ms / 1000);
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return rest ? `${minutes} 分 ${rest} 秒` : `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
export function runStatusLabel(status: string | null | undefined): string {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "执行成功";
|
||||
case "failed":
|
||||
return "执行失败";
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "pending":
|
||||
return "排队中";
|
||||
default:
|
||||
return "暂无执行";
|
||||
}
|
||||
}
|
||||
|
||||
export type MatTaskBadge = {
|
||||
label: string;
|
||||
theme: "success" | "warning" | "danger" | "default";
|
||||
};
|
||||
|
||||
/** 任务卡右上角的状态徽标:以最近一次执行状态优先,其次启停。 */
|
||||
export function taskBadgeOf(task: MatTaskView): MatTaskBadge {
|
||||
const status = task.lastRun?.status;
|
||||
if (status === "running" || status === "pending") {
|
||||
return { label: "执行中", theme: "warning" };
|
||||
}
|
||||
if (!task.enabled) return { label: "已暂停", theme: "default" };
|
||||
if (status === "failed") return { label: "上次失败", theme: "danger" };
|
||||
if (status === "succeeded") return { label: "运行正常", theme: "success" };
|
||||
return { label: "已启用", theme: "success" };
|
||||
}
|
||||
|
||||
/** 席位配色槽位:按 agent_id 稳定散列到 0..7。 */
|
||||
export function seatColorIndex(agentId: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < agentId.length; i += 1) {
|
||||
hash = (hash * 31 + agentId.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash % 8;
|
||||
}
|
||||
|
||||
export function seatIndexOf(seats: MatSeat[], agentId: string): number {
|
||||
const index = seats.findIndex((seat) => seat.agent_id === agentId);
|
||||
return index >= 0 ? index : seats.length;
|
||||
}
|
||||
|
||||
export function defaultScheduleConfig(): ScheduleConfig {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG };
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 任务分析 · 详情页:左侧执行记录(定时 / 手动),右侧按选中运行回放流程图
|
||||
* (业务链风格节点状态 + 耗时,5 秒自动刷新),点击智能体节点打开执行过程抽屉。
|
||||
*/
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CircleAlert,
|
||||
GitBranch,
|
||||
MessageSquareText,
|
||||
Pencil,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Switch, Tag } from "tdesign-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatRelative,
|
||||
} from "@/pages/official-multi-agent-tasks/utils";
|
||||
|
||||
import BindingDialog from "./components/BindingDialog";
|
||||
import SeatProcessDrawer from "./components/SeatProcessDrawer";
|
||||
import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
|
||||
import { taskBadgeOf } from "./badge";
|
||||
import { useTaTaskActions, useTaTasks, useTaTaskRuns } from "./store";
|
||||
|
||||
const RUN_STATUS_TEXT: Record<string, string> = {
|
||||
succeeded: "成功",
|
||||
failed: "失败",
|
||||
running: "执行中",
|
||||
pending: "排队中",
|
||||
};
|
||||
|
||||
const RUN_STATUS_THEME: Record<string, "success" | "danger" | "warning" | "default"> = {
|
||||
succeeded: "success",
|
||||
failed: "danger",
|
||||
running: "warning",
|
||||
pending: "default",
|
||||
};
|
||||
|
||||
export default function TaskAnalysisDetailPage({
|
||||
routeTaskId,
|
||||
}: {
|
||||
routeTaskId: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { views, isLoading } = useTaTasks();
|
||||
const actions = useTaTaskActions();
|
||||
const taskView = views.find(({ task }) => task.id === routeTaskId) ?? null;
|
||||
const task = taskView?.task ?? null;
|
||||
const { runs, isLoading: runsLoading } = useTaTaskRuns(task?.id ?? null);
|
||||
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [seatAgentId, setSeatAgentId] = useState<string | null>(null);
|
||||
const [bindingVisible, setBindingVisible] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// 默认选中最近一次运行;新触发的运行自动跟随。
|
||||
useEffect(() => {
|
||||
if (!runs.length) return;
|
||||
if (!selectedRunId || !runs.some((run) => run.id === selectedRunId)) {
|
||||
setSelectedRunId(runs[0]!.id);
|
||||
}
|
||||
}, [runs, selectedRunId]);
|
||||
|
||||
const selectedRun = useMemo(
|
||||
() => runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null,
|
||||
[runs, selectedRunId],
|
||||
);
|
||||
|
||||
const backToList = () => {
|
||||
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis`);
|
||||
};
|
||||
|
||||
const runTrigger = async (manual: boolean) => {
|
||||
if (!task) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (manual) {
|
||||
await actions.triggerManualRun.mutateAsync(task.id);
|
||||
toast.success("已发起手动执行(总控问答)");
|
||||
} else {
|
||||
await actions.triggerRun.mutateAsync(task.id);
|
||||
toast.success("已触发定时执行");
|
||||
}
|
||||
setSelectedRunId(null);
|
||||
} catch {
|
||||
toast.error("任务未绑定分析方案");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runToggle = async () => {
|
||||
if (!task?.binding) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await actions.setEnabled.mutateAsync({
|
||||
taskId: task.id,
|
||||
enabled: !task.binding?.enabled,
|
||||
});
|
||||
toast.success(task.binding?.enabled ? "已暂停" : "已恢复");
|
||||
} catch {
|
||||
toast.error("操作失败,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoading && !task) {
|
||||
return (
|
||||
<section className="ta-page">
|
||||
<div className="ta-state ta-state--empty">
|
||||
<h3>任务不存在或已被删除</h3>
|
||||
<Button theme="primary" onClick={backToList}>
|
||||
返回任务列表
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const binding = task?.binding ?? null;
|
||||
const taskBadge = task && taskView ? taskBadgeOf(task, taskView.runs) : null;
|
||||
const live = selectedRun?.status === "running" || selectedRun?.status === "pending";
|
||||
|
||||
return (
|
||||
<section className="ta-page ta-detail-page">
|
||||
<header className="ta-detail-head">
|
||||
<button
|
||||
aria-label="返回任务列表"
|
||||
className="ta-back"
|
||||
onClick={backToList}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div className="ta-detail-heading">
|
||||
<h1>
|
||||
{task?.name ?? "…"}
|
||||
<em>{task?.sourceTaskId}</em>
|
||||
</h1>
|
||||
<p>
|
||||
{binding ? (
|
||||
<>
|
||||
<GitBranch aria-hidden="true" size={13} />
|
||||
{binding.chainTitle} · {binding.cronText}
|
||||
{binding.directionNotes.length > 0 &&
|
||||
` · ${binding.directionNotes.length} 条方向备注`}
|
||||
</>
|
||||
) : (
|
||||
task?.description
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{taskBadge && (
|
||||
<Tag theme={taskBadge.theme} variant="light">
|
||||
{taskBadge.label}
|
||||
</Tag>
|
||||
)}
|
||||
{binding && (
|
||||
<Switch value={binding.enabled} onChange={() => void runToggle()} />
|
||||
)}
|
||||
<div className="ta-detail-ops">
|
||||
{binding && (
|
||||
<>
|
||||
<Button
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
theme="primary"
|
||||
onClick={() => void runTrigger(false)}
|
||||
>
|
||||
<Zap aria-hidden="true" size={15} />
|
||||
立即执行
|
||||
</Button>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void runTrigger(true)}>
|
||||
<MessageSquareText aria-hidden="true" size={15} />
|
||||
模拟手动执行
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setBindingVisible(true)}>
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
{binding ? "编辑绑定" : "配置分析"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!binding ? (
|
||||
<div className="ta-detail-empty">
|
||||
<GitBranch aria-hidden="true" size={26} />
|
||||
<h3>尚未配置分析方案</h3>
|
||||
<p>绑定业务链与内容块映射后,这里将展示执行记录与各智能体的执行流程。</p>
|
||||
<Button theme="primary" onClick={() => setBindingVisible(true)}>
|
||||
配置分析
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ta-detail-layout">
|
||||
<aside className="ta-runs">
|
||||
<header className="ta-runs-head">
|
||||
<h2>执行记录</h2>
|
||||
<span>
|
||||
<RefreshCw aria-hidden="true" className={live ? "ta-spin" : undefined} size={12} />
|
||||
每 5 秒刷新
|
||||
</span>
|
||||
</header>
|
||||
<div className="ta-runs-list">
|
||||
{runsLoading && !runs.length ? (
|
||||
<p className="ta-runs-empty">正在加载执行记录…</p>
|
||||
) : runs.length ? (
|
||||
runs.map((run) => {
|
||||
const active = selectedRun?.id === run.id;
|
||||
const duration = run.finishedAt
|
||||
? formatDuration(
|
||||
new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime(),
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
className={`ta-run-item${active ? " is-active" : ""}`}
|
||||
key={run.id}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="ta-run-item-top">
|
||||
{run.trigger === "manual" ? (
|
||||
<MessageSquareText aria-hidden="true" size={13} />
|
||||
) : (
|
||||
<CalendarClock aria-hidden="true" size={13} />
|
||||
)}
|
||||
<b>{run.trigger === "manual" ? "手动执行" : "定时执行"}</b>
|
||||
<Tag size="small" theme={RUN_STATUS_THEME[run.status] ?? "default"} variant="light">
|
||||
{RUN_STATUS_TEXT[run.status] ?? run.status}
|
||||
</Tag>
|
||||
</span>
|
||||
<span className="ta-run-item-time">
|
||||
{formatDateTime(run.startedAt)} · {duration ?? "进行中"}
|
||||
</span>
|
||||
{run.trigger === "manual" && run.manualSummary && (
|
||||
<span className="ta-run-item-summary">{run.manualSummary}</span>
|
||||
)}
|
||||
{run.trigger === "schedule" && (
|
||||
<span className="ta-run-item-blocks">
|
||||
内容块 {run.blockStates.filter((b) => b.state === "done").length}/
|
||||
{run.blockStates.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="ta-runs-empty">还没有执行记录,点「立即执行」体验完整流程。</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="ta-flow-panel">
|
||||
{selectedRun ? (
|
||||
<>
|
||||
<header className="ta-flow-head">
|
||||
<h2>
|
||||
执行流程
|
||||
<small>
|
||||
{selectedRun.trigger === "manual" ? "手动执行" : "定时执行"} ·{" "}
|
||||
{formatRelative(selectedRun.startedAt)}
|
||||
{selectedRun.status === "running" &&
|
||||
` · 阶段 ${Math.min(selectedRun.stageIndex + 1, selectedRun.stageTotal)}/${selectedRun.stageTotal} 执行中`}
|
||||
</small>
|
||||
</h2>
|
||||
<span className="ta-flow-legend">
|
||||
<i className="ta-dot is-done" /> 完成
|
||||
<i className="ta-dot is-running" /> 执行中
|
||||
<i className="ta-dot is-waiting" /> 等待
|
||||
<i className="ta-dot is-failed" /> 失败
|
||||
</span>
|
||||
</header>
|
||||
<div className="ta-flow-canvas">
|
||||
<ChainFlowDiagram
|
||||
className="ta-flow-diagram"
|
||||
onNodeClick={(agentId) => setSeatAgentId(agentId)}
|
||||
seatStates={selectedRun.seatInfos}
|
||||
stages={selectedRun.stages.map((stage, index) => ({
|
||||
seats: stage.map((agentId) => ({
|
||||
agent_id: agentId,
|
||||
name: selectedRun.seatNames[agentId] ?? agentId,
|
||||
})),
|
||||
goal: selectedRun.stageGoals?.[index] ?? null,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{selectedRun.error && (
|
||||
<p className="ta-flow-error">
|
||||
<CircleAlert aria-hidden="true" size={14} />
|
||||
{selectedRun.error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="ta-flow-panel-empty">选择左侧一条执行记录查看流程</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SeatProcessDrawer
|
||||
onClose={() => setSeatAgentId(null)}
|
||||
run={selectedRun}
|
||||
seatAgentId={seatAgentId}
|
||||
task={task ?? undefined}
|
||||
/>
|
||||
|
||||
<BindingDialog
|
||||
onClose={() => setBindingVisible(false)}
|
||||
onSubmit={async (payload) => {
|
||||
if (!task) return;
|
||||
try {
|
||||
await actions.bind.mutateAsync({ taskId: task.id, payload });
|
||||
toast.success(binding ? "绑定已更新" : "分析方案已绑定");
|
||||
setBindingVisible(false);
|
||||
} catch {
|
||||
toast.error("保存失败,请重试");
|
||||
}
|
||||
}}
|
||||
submitting={actions.bind.isPending}
|
||||
task={task}
|
||||
visible={bindingVisible}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* 任务分析 · 列表页:对方系统推送过来的外部任务 + 我们的绑定状态。
|
||||
* 任务卡片沿用多智能体定时任务的卡片视觉(浅红规范)。
|
||||
*/
|
||||
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
Clock3,
|
||||
GitBranch,
|
||||
Link2,
|
||||
Network,
|
||||
Pencil,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
DialogPlugin,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from "tdesign-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { OFFICIAL_DEERFLOW_ROUTE_PREFIX } from "@/core/official-deerflow/routes";
|
||||
|
||||
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
|
||||
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatRelative,
|
||||
} from "@/pages/official-multi-agent-tasks/utils";
|
||||
|
||||
import BindingDialog from "./components/BindingDialog";
|
||||
import CreateTaskDialog, {
|
||||
type TaCreateTaskPayload,
|
||||
} from "./components/CreateTaskDialog";
|
||||
import { chainStagesOf, useTaTaskActions, useTaTasks } from "./store";
|
||||
import type { TaTask, TaTaskViewLike } from "./page-types";
|
||||
import { taskBadgeOf } from "./badge";
|
||||
|
||||
import "./task-analysis.css";
|
||||
|
||||
type StatusFilter = "all" | "unbound" | "enabled" | "paused" | "failed";
|
||||
|
||||
function isTaskLive(runs: TaTaskViewLike["runs"]) {
|
||||
return runs.some((run) => run.status === "running" || run.status === "pending");
|
||||
}
|
||||
|
||||
export default function TaskAnalysisPage() {
|
||||
const navigate = useNavigate();
|
||||
const { views, isLoading } = useTaTasks();
|
||||
const actions = useTaTaskActions();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||
const [bindingTask, setBindingTask] = useState<TaTask | null>(null);
|
||||
const [bindingVisible, setBindingVisible] = useState(false);
|
||||
const [createVisible, setCreateVisible] = useState(false);
|
||||
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
|
||||
|
||||
const visibleViews = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return views.filter(({ task, runs }) => {
|
||||
const lastStatus = runs[0]?.status ?? null;
|
||||
const bound = !!task.binding;
|
||||
const live = isTaskLive(runs);
|
||||
if (filter === "unbound" && bound) return false;
|
||||
if (filter === "enabled" && (!bound || !task.binding!.enabled || live)) return false;
|
||||
if (filter === "paused" && (!bound || task.binding!.enabled)) return false;
|
||||
if (filter === "failed" && lastStatus !== "failed") return false;
|
||||
if (!keyword) return true;
|
||||
return (
|
||||
task.name.toLowerCase().includes(keyword) ||
|
||||
task.description.toLowerCase().includes(keyword) ||
|
||||
task.sourceTaskId.toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
}, [views, search, filter]);
|
||||
|
||||
const stats = useMemo(
|
||||
() => ({
|
||||
total: views.length,
|
||||
unbound: views.filter(({ task }) => !task.binding).length,
|
||||
enabled: views.filter(({ task }) => task.binding?.enabled).length,
|
||||
failed: views.filter(({ runs }) => runs[0]?.status === "failed").length,
|
||||
}),
|
||||
[views],
|
||||
);
|
||||
|
||||
const openDetail = (taskId: string) => {
|
||||
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/task-analysis/${encodeURIComponent(taskId)}`);
|
||||
};
|
||||
|
||||
const openBinding = (task: TaTask) => {
|
||||
setBindingTask(task);
|
||||
setBindingVisible(true);
|
||||
};
|
||||
|
||||
const handleCreate = async (payload: TaCreateTaskPayload) => {
|
||||
try {
|
||||
const created = await actions.createTask.mutateAsync({
|
||||
name: payload.name,
|
||||
sourceTaskId: payload.sourceTaskId,
|
||||
description: payload.description,
|
||||
});
|
||||
await actions.bind.mutateAsync({ taskId: created.id, payload: payload.binding });
|
||||
toast.success("任务已创建并绑定分析方案");
|
||||
setCreateVisible(false);
|
||||
} catch {
|
||||
toast.error("创建失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBind = async (payload: Parameters<typeof actions.bind.mutateAsync>[0]["payload"]) => {
|
||||
if (!bindingTask) return;
|
||||
try {
|
||||
await actions.bind.mutateAsync({ taskId: bindingTask.id, payload });
|
||||
toast.success(bindingTask.binding ? "绑定已更新" : "分析方案已绑定");
|
||||
setBindingVisible(false);
|
||||
setBindingTask(null);
|
||||
} catch {
|
||||
toast.error("保存失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
const runTrigger = async (taskId: string) => {
|
||||
setBusyTaskId(taskId);
|
||||
try {
|
||||
await actions.triggerRun.mutateAsync(taskId);
|
||||
toast.success("已触发执行");
|
||||
openDetail(taskId);
|
||||
} catch {
|
||||
toast.error("触发失败:任务未绑定分析方案");
|
||||
} finally {
|
||||
setBusyTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runToggle = async (task: TaTask) => {
|
||||
setBusyTaskId(task.id);
|
||||
try {
|
||||
await actions.setEnabled.mutateAsync({ taskId: task.id, enabled: !task.binding?.enabled });
|
||||
toast.success(task.binding?.enabled ? "已暂停" : "已恢复");
|
||||
} catch {
|
||||
toast.error("操作失败,请重试");
|
||||
} finally {
|
||||
setBusyTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (task: TaTask) => {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: "删除外部任务",
|
||||
body: `删除「${task.name}」将同时清除其绑定与全部执行记录(对应对外删除接口语义),确定删除?`,
|
||||
confirmBtn: { content: "删除", theme: "danger" },
|
||||
cancelBtn: "取消",
|
||||
onConfirm: () => {
|
||||
dialog.destroy();
|
||||
void actions.removeTask.mutateAsync(task.id).then(() => {
|
||||
toast.success("任务已删除");
|
||||
});
|
||||
},
|
||||
onClose: () => dialog.destroy(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ta-page">
|
||||
<header className="ta-page-head">
|
||||
<div className="ta-page-head-copy">
|
||||
<h1>任务分析定时</h1>
|
||||
<p>
|
||||
外部系统任务接入多智能体定时分析:绑定业务链与内容块映射,按计划执行并沉淀时间线版本。
|
||||
</p>
|
||||
</div>
|
||||
<div className="ta-page-head-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const unbound = views.find(({ task }) => !task.binding)?.task;
|
||||
if (unbound) openBinding(unbound);
|
||||
else toast.info("当前任务均已绑定分析方案");
|
||||
}}
|
||||
>
|
||||
配置未绑定任务
|
||||
</Button>
|
||||
<Button theme="primary" onClick={() => setCreateVisible(true)}>
|
||||
<Plus aria-hidden="true" size={15} />
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="ta-stats" aria-label="任务统计">
|
||||
<div className="ta-stat">
|
||||
<span className="ta-stat-icon is-primary" aria-hidden="true">
|
||||
<Network size={18} />
|
||||
</span>
|
||||
<div className="ta-stat-copy">
|
||||
<strong>{stats.total}</strong>
|
||||
<span>外部任务</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ta-stat">
|
||||
<span className="ta-stat-icon is-warning" aria-hidden="true">
|
||||
<Link2 size={18} />
|
||||
</span>
|
||||
<div className="ta-stat-copy">
|
||||
<strong>{stats.unbound}</strong>
|
||||
<span>未配置</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ta-stat">
|
||||
<span className="ta-stat-icon is-success" aria-hidden="true">
|
||||
<CircleCheck size={18} />
|
||||
</span>
|
||||
<div className="ta-stat-copy">
|
||||
<strong>{stats.enabled}</strong>
|
||||
<span>已启用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ta-stat">
|
||||
<span className="ta-stat-icon is-danger" aria-hidden="true">
|
||||
<CircleAlert size={18} />
|
||||
</span>
|
||||
<div className="ta-stat-copy">
|
||||
<strong>{stats.failed}</strong>
|
||||
<span>最近失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ta-body">
|
||||
<div className="ta-toolbar">
|
||||
<div className="ta-toolbar-search">
|
||||
<Input
|
||||
clearable
|
||||
placeholder="搜索任务名称 / 简介 / 对方任务号"
|
||||
prefixIcon={<Search aria-hidden="true" size={15} />}
|
||||
value={search}
|
||||
onChange={(value) => setSearch(String(value ?? ""))}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
className="ta-toolbar-tabs"
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as StatusFilter)}
|
||||
>
|
||||
<Tabs.TabPanel label="全部" value="all" />
|
||||
<Tabs.TabPanel label="未配置" value="unbound" />
|
||||
<Tabs.TabPanel label="已启用" value="enabled" />
|
||||
<Tabs.TabPanel label="已暂停" value="paused" />
|
||||
<Tabs.TabPanel label="最近失败" value="failed" />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="ta-state">
|
||||
<Clock3 aria-hidden="true" className="ta-spin" size={18} />
|
||||
正在加载外部任务…
|
||||
</div>
|
||||
) : visibleViews.length ? (
|
||||
<div className="ta-grid">
|
||||
{visibleViews.map(({ task, runs }) => {
|
||||
const badge = taskBadgeOf(task, runs);
|
||||
const binding = task.binding;
|
||||
const lastRun = runs[0] ?? null;
|
||||
const busy = busyTaskId === task.id;
|
||||
const stages = binding ? chainStagesOf(binding) : [];
|
||||
const seats: MatSeat[] = stages
|
||||
.flat()
|
||||
.filter((id, index, all) => all.indexOf(id) === index)
|
||||
.map((id) => ({
|
||||
agent_id: id,
|
||||
name: lastRun?.seatNames[id] ?? id,
|
||||
}));
|
||||
return (
|
||||
<article className="ta-card" key={task.id}>
|
||||
<header className="ta-card-head" onClick={() => openDetail(task.id)}>
|
||||
<span className="ta-card-glyph" aria-hidden="true">
|
||||
<Network size={17} />
|
||||
</span>
|
||||
<div className="ta-card-title">
|
||||
<h3>
|
||||
{task.name}
|
||||
<em>{task.sourceTaskId}</em>
|
||||
</h3>
|
||||
<p>{task.description}</p>
|
||||
</div>
|
||||
<Tag size="small" theme={badge.theme} variant="light">
|
||||
{badge.label}
|
||||
</Tag>
|
||||
</header>
|
||||
|
||||
{binding ? (
|
||||
<>
|
||||
<div className="ta-card-binding" onClick={() => openDetail(task.id)}>
|
||||
<span>
|
||||
<GitBranch aria-hidden="true" size={13} />
|
||||
{binding.chainTitle}
|
||||
</span>
|
||||
<span>
|
||||
<CalendarClock aria-hidden="true" size={13} />
|
||||
{binding.cronText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ta-card-flow" onClick={() => openDetail(task.id)}>
|
||||
<AgentFlow seats={seats} size="sm" stages={stages} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button className="ta-card-unbound" onClick={() => openBinding(task)} type="button">
|
||||
<GitBranch aria-hidden="true" size={15} />
|
||||
配置分析:选择业务链 · 内容块映射 · 执行频率
|
||||
</button>
|
||||
)}
|
||||
|
||||
<footer className="ta-card-foot">
|
||||
<div className="ta-card-meta">
|
||||
<span>
|
||||
下次{" "}
|
||||
{binding?.enabled
|
||||
? formatDateTime(nextRunAt(binding.cron))
|
||||
: binding
|
||||
? "已暂停"
|
||||
: "未配置"}
|
||||
</span>
|
||||
<span>
|
||||
上次 {lastRun ? formatRelative(lastRun.startedAt) : "未执行"}
|
||||
{lastRun && lastRun.status !== "pending" && (
|
||||
<em className={`ta-run-text is-${lastRun.status}`}>
|
||||
· {lastRun.status === "succeeded" ? "成功" : lastRun.status === "failed" ? "失败" : "执行中"}
|
||||
</em>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ta-card-ops">
|
||||
{binding && (
|
||||
<>
|
||||
<Button
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
size="small"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
onClick={() => void runTrigger(task.id)}
|
||||
>
|
||||
<Zap aria-hidden="true" size={14} />
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => void runToggle(task)}
|
||||
>
|
||||
{binding.enabled ? (
|
||||
<Pause aria-hidden="true" size={14} />
|
||||
) : (
|
||||
<Play aria-hidden="true" size={14} />
|
||||
)}
|
||||
{binding.enabled ? "暂停" : "恢复"}
|
||||
</Button>
|
||||
<Button size="small" variant="text" onClick={() => openBinding(task)}>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
编辑绑定
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Popconfirm
|
||||
content="删除后绑定与执行记录一并清除,确定?"
|
||||
placement="bottom-right"
|
||||
theme="danger"
|
||||
onConfirm={() => confirmDelete(task)}
|
||||
>
|
||||
<Button className="ta-danger-btn" size="small" variant="text">
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ta-state ta-state--empty">
|
||||
<span className="ta-state-glyph" aria-hidden="true">
|
||||
<Network size={22} />
|
||||
</span>
|
||||
<h3>{views.length ? "没有符合条件的任务" : "还没有外部任务"}</h3>
|
||||
<p>
|
||||
{views.length
|
||||
? "换个关键词或筛选条件试试。"
|
||||
: "对方系统创建任务并调用创建接口后,任务会出现在这里。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="ta-footnote">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
前端演示阶段:任务与执行数据保存在本浏览器,业务链来自真实接口;后端服务接入后自动切换。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CreateTaskDialog
|
||||
onClose={() => setCreateVisible(false)}
|
||||
onSubmit={handleCreate}
|
||||
submitting={actions.createTask.isPending}
|
||||
visible={createVisible}
|
||||
/>
|
||||
|
||||
<BindingDialog
|
||||
onClose={() => {
|
||||
setBindingVisible(false);
|
||||
setBindingTask(null);
|
||||
}}
|
||||
onSubmit={handleBind}
|
||||
submitting={actions.bind.isPending}
|
||||
task={bindingTask}
|
||||
visible={bindingVisible}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 简易下次执行时间(覆盖本页生成的 cron 形态)。 */
|
||||
function nextRunAt(cron: string): string | null {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [min, hour, , , wd] = parts;
|
||||
const now = new Date();
|
||||
const candidate = new Date(now);
|
||||
const minute = parseInt(min ?? "0", 10);
|
||||
const hourNum = parseInt(hour ?? "9", 10);
|
||||
if (`${hour}` === "*") {
|
||||
candidate.setMinutes(minute, 0, 0);
|
||||
if (candidate <= now) candidate.setHours(candidate.getHours() + 1);
|
||||
} else if (`${wd}` !== "*") {
|
||||
const weekday = parseInt(wd ?? "1", 10);
|
||||
candidate.setHours(hourNum, minute, 0, 0);
|
||||
let delta = (weekday - candidate.getDay() + 7) % 7;
|
||||
if (delta === 0 && candidate <= now) delta = 7;
|
||||
candidate.setDate(candidate.getDate() + delta);
|
||||
} else {
|
||||
candidate.setHours(hourNum, minute, 0, 0);
|
||||
if (candidate <= now) candidate.setDate(candidate.getDate() + 1);
|
||||
}
|
||||
return candidate.toISOString();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TaRunView, TaTask } from "./types";
|
||||
|
||||
export interface TaBadge {
|
||||
label: string;
|
||||
theme: "success" | "warning" | "danger" | "default";
|
||||
}
|
||||
|
||||
/** 任务卡右上角状态徽标:最近一次执行优先,其次启停,未绑定给引导态。 */
|
||||
export function taskBadgeOf(task: TaTask, runs: TaRunView[]): TaBadge {
|
||||
const status = runs[0]?.status;
|
||||
if (status === "running" || status === "pending") {
|
||||
return { label: "执行中", theme: "warning" };
|
||||
}
|
||||
if (!task.binding) return { label: "未配置", theme: "default" };
|
||||
if (!task.binding.enabled) return { label: "已暂停", theme: "default" };
|
||||
if (status === "failed") return { label: "上次失败", theme: "danger" };
|
||||
if (status === "succeeded") return { label: "运行正常", theme: "success" };
|
||||
return { label: "已启用", theme: "success" };
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 绑定配置弹窗:选业务链(真实 /api/roundtable-chains + 内置演示链)→ 配置
|
||||
* 对方详情块 → 席位映射 → 执行频率 / 启用 / 总控提示。
|
||||
*/
|
||||
|
||||
import { Radio } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Dialog, Switch, Textarea } from "tdesign-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { listChains } from "@/roundtable-planning/api/chains";
|
||||
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
|
||||
import { type ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
|
||||
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
|
||||
import SchedulePicker from "@/pages/official-multi-agent-tasks/components/SchedulePicker";
|
||||
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
|
||||
import {
|
||||
defaultScheduleConfig,
|
||||
describeScheduleConfig,
|
||||
} from "@/pages/official-multi-agent-tasks/utils";
|
||||
|
||||
import type { TaBinding, TaBindingPayload, TaTask } from "../types";
|
||||
|
||||
/** 内置演示链(种子里用它,保证无后端数据时链路可演示)。 */
|
||||
export const SEED_CHAIN: BusinessChain = {
|
||||
id: "seed-chain-std",
|
||||
title: "标准舆情分析链(内置)",
|
||||
description: "采集 + 核查并行 → 研判 → 成稿,演示用内置编排。",
|
||||
seats: [
|
||||
{ agent_id: "collector", name: "全网舆情采集" },
|
||||
{ agent_id: "verifier", name: "数据核查员" },
|
||||
{ agent_id: "analyst", name: "舆情研判分析" },
|
||||
{ agent_id: "writer", name: "报告撰写助手" },
|
||||
],
|
||||
stages: [
|
||||
["collector", "verifier"],
|
||||
["analyst"],
|
||||
["writer"],
|
||||
],
|
||||
stageGoals: null,
|
||||
isPublic: true,
|
||||
};
|
||||
|
||||
export function chainStages(chain: BusinessChain): string[][] {
|
||||
if (chain.stages?.length) return chain.stages;
|
||||
return chain.seats.map((seat) => [seat.agent_id]);
|
||||
}
|
||||
|
||||
export function chainSeats(chain: BusinessChain): MatSeat[] {
|
||||
return chain.seats.map((seat) => ({
|
||||
agent_id: seat.agent_id,
|
||||
name: seat.name,
|
||||
description: seat.description,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 模块级空数组:data 未就绪时保持引用稳定,避免 effect 依赖无限循环。 */
|
||||
export const NO_CHAINS: BusinessChain[] = [];
|
||||
|
||||
export default function BindingDialog({
|
||||
visible,
|
||||
task,
|
||||
submitting,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
visible: boolean;
|
||||
task: TaTask | null;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: TaBindingPayload) => void;
|
||||
}) {
|
||||
const [chainId, setChainId] = useState("");
|
||||
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
|
||||
|
||||
const { data, isLoading: chainsLoading } = useQuery({
|
||||
queryKey: ["ta", "chains"],
|
||||
queryFn: async () => {
|
||||
const [mine, publicChains] = await Promise.all([
|
||||
listChains("mine").catch(() => [] as BusinessChain[]),
|
||||
listChains("public").catch(() => [] as BusinessChain[]),
|
||||
]);
|
||||
const byId = new Map<string, BusinessChain>();
|
||||
for (const chain of [SEED_CHAIN, ...mine, ...publicChains]) {
|
||||
if (!byId.has(chain.id)) byId.set(chain.id, chain);
|
||||
}
|
||||
return [...byId.values()];
|
||||
},
|
||||
enabled: visible,
|
||||
});
|
||||
const chains = data ?? NO_CHAINS;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const binding: TaBinding | null = task?.binding ?? null;
|
||||
const initialChain =
|
||||
chains.find((chain) => chain.id === binding?.chainId) ??
|
||||
chains[0] ??
|
||||
SEED_CHAIN;
|
||||
setChainId(binding?.chainId ?? initialChain.id);
|
||||
setEnabled(binding?.enabled ?? true);
|
||||
setCoordinatorPrompt(binding?.coordinatorPrompt ?? "");
|
||||
}, [visible, task, chains]);
|
||||
|
||||
const selectedChain = useMemo(
|
||||
() => chains.find((chain) => chain.id === chainId) ?? null,
|
||||
[chains, chainId],
|
||||
);
|
||||
const handleChainChange = (nextId: string) => {
|
||||
setChainId(nextId);
|
||||
};
|
||||
|
||||
const trySubmit = () => {
|
||||
if (!selectedChain) {
|
||||
toast.warning("请选择业务链");
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
chainId: selectedChain.id,
|
||||
chainTitle: selectedChain.title,
|
||||
schedule,
|
||||
enabled,
|
||||
coordinatorPrompt: coordinatorPrompt.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="ta-binding-dialog"
|
||||
closeBtn
|
||||
closeOnOverlayClick={false}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="ta-dialog-footer">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button loading={submitting} theme="primary" onClick={trySubmit}>
|
||||
{task?.binding ? "保存绑定" : "创建绑定"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
header={task?.binding ? `编辑绑定 · ${task.name}` : `配置分析 · ${task?.name ?? ""}`}
|
||||
placement="top"
|
||||
visible={visible}
|
||||
width={860}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="ta-binding">
|
||||
<section className="ta-binding-section">
|
||||
<header className="ta-binding-section-head">
|
||||
<h4>选择业务链</h4>
|
||||
<span>定时执行将按业务链的多智能体关系(同阶段并行 · 阶段间串行)运行</span>
|
||||
</header>
|
||||
{chainsLoading ? (
|
||||
<p className="ta-binding-tip">正在加载业务链…</p>
|
||||
) : (
|
||||
<div className="ta-chain-grid">
|
||||
{chains.map((chain) => {
|
||||
const active = chain.id === chainId;
|
||||
return (
|
||||
<button
|
||||
className={`ta-chain-card${active ? " is-active" : ""}`}
|
||||
key={chain.id}
|
||||
onClick={() => handleChainChange(chain.id)}
|
||||
type="button"
|
||||
>
|
||||
<header>
|
||||
<Radio aria-hidden="true" size={14} />
|
||||
<span className="ta-chain-card-title">{chain.title}</span>
|
||||
<em>
|
||||
{chain.seats.length} 智能体 · {chainStages(chain).length} 阶段
|
||||
</em>
|
||||
</header>
|
||||
{chain.description && <p>{chain.description}</p>}
|
||||
<div className="ta-chain-card-flow">
|
||||
<AgentFlow
|
||||
seats={chainSeats(chain)}
|
||||
size="sm"
|
||||
stages={chainStages(chain)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ta-binding-section">
|
||||
<header className="ta-binding-section-head">
|
||||
<h4>执行设置</h4>
|
||||
<span>{describeScheduleConfig(schedule)} 自动执行</span>
|
||||
</header>
|
||||
<div className="ta-binding-settings">
|
||||
<div className="ta-field">
|
||||
<span className="ta-field-label">执行频率</span>
|
||||
<SchedulePicker value={schedule} onChange={setSchedule} />
|
||||
</div>
|
||||
<label className="ta-field">
|
||||
<span className="ta-field-label">总控提示(可选)</span>
|
||||
<Textarea
|
||||
autosize={{ minRows: 2, maxRows: 4 }}
|
||||
maxlength={500}
|
||||
placeholder="写给总控(协调者)的编排要求;问答沉淀的「方向备注」也会注入后续执行"
|
||||
value={coordinatorPrompt}
|
||||
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
|
||||
/>
|
||||
</label>
|
||||
<div className="ta-field ta-field--switch">
|
||||
<span className="ta-field-label">绑定后立即启用</span>
|
||||
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 新建任务弹窗(一步式):任务信息(名称/任务号/简介)+ 业务链选择 +
|
||||
* 执行设置(频率 / 总控提示 / 启用),提交后直接创建任务并完成绑定。
|
||||
*/
|
||||
|
||||
import { Radio } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Dialog, Input, Switch, Textarea } from "tdesign-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { listChains } from "@/roundtable-planning/api/chains";
|
||||
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
|
||||
import { type ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
|
||||
import AgentFlow from "@/pages/official-multi-agent-tasks/components/AgentFlow";
|
||||
import SchedulePicker from "@/pages/official-multi-agent-tasks/components/SchedulePicker";
|
||||
import {
|
||||
defaultScheduleConfig,
|
||||
describeScheduleConfig,
|
||||
} from "@/pages/official-multi-agent-tasks/utils";
|
||||
import type { MatSeat } from "@/pages/official-multi-agent-tasks/types";
|
||||
|
||||
import { NO_CHAINS, SEED_CHAIN, chainSeats, chainStages } from "./BindingDialog";
|
||||
import type { TaBindingPayload } from "../types";
|
||||
|
||||
export interface TaCreateTaskPayload {
|
||||
name: string;
|
||||
sourceTaskId?: string;
|
||||
description?: string;
|
||||
binding: TaBindingPayload;
|
||||
}
|
||||
|
||||
export default function CreateTaskDialog({
|
||||
visible,
|
||||
submitting,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
visible: boolean;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: TaCreateTaskPayload) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [sourceTaskId, setSourceTaskId] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [chainId, setChainId] = useState("");
|
||||
const [schedule, setSchedule] = useState<ScheduleConfig>(defaultScheduleConfig());
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
|
||||
|
||||
const { data, isLoading: chainsLoading } = useQuery({
|
||||
queryKey: ["ta", "chains"],
|
||||
queryFn: async () => {
|
||||
const [mine, publicChains] = await Promise.all([
|
||||
listChains("mine").catch(() => [] as BusinessChain[]),
|
||||
listChains("public").catch(() => [] as BusinessChain[]),
|
||||
]);
|
||||
const byId = new Map<string, BusinessChain>();
|
||||
for (const chain of [SEED_CHAIN, ...mine, ...publicChains]) {
|
||||
if (!byId.has(chain.id)) byId.set(chain.id, chain);
|
||||
}
|
||||
return [...byId.values()];
|
||||
},
|
||||
enabled: visible,
|
||||
});
|
||||
const chains = data ?? NO_CHAINS;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setName("");
|
||||
setSourceTaskId("");
|
||||
setDescription("");
|
||||
setSchedule(defaultScheduleConfig());
|
||||
setEnabled(true);
|
||||
setCoordinatorPrompt("");
|
||||
setChainId((prev) => (chains.some((chain) => chain.id === prev) ? prev : chains[0]?.id ?? SEED_CHAIN.id));
|
||||
}, [visible, chains]);
|
||||
|
||||
const selectedChain = useMemo(
|
||||
() => chains.find((chain) => chain.id === chainId) ?? null,
|
||||
[chains, chainId],
|
||||
);
|
||||
|
||||
const trySubmit = () => {
|
||||
if (!name.trim()) {
|
||||
toast.warning("请填写任务名称");
|
||||
return;
|
||||
}
|
||||
if (!selectedChain) {
|
||||
toast.warning("请选择业务链");
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
name,
|
||||
sourceTaskId: sourceTaskId.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
binding: {
|
||||
chainId: selectedChain.id,
|
||||
chainTitle: selectedChain.title,
|
||||
schedule,
|
||||
enabled,
|
||||
coordinatorPrompt: coordinatorPrompt.trim() || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="ta-create-dialog"
|
||||
closeBtn
|
||||
closeOnOverlayClick={false}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="ta-dialog-footer">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button loading={submitting} theme="primary" onClick={trySubmit}>
|
||||
创建任务并绑定
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
header="新建任务"
|
||||
placement="top"
|
||||
visible={visible}
|
||||
width={860}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="ta-binding">
|
||||
<section className="ta-binding-section">
|
||||
<header className="ta-binding-section-head">
|
||||
<h4>任务信息</h4>
|
||||
<span>由本页录入;接入对方系统后,推送创建的任务同样出现在列表中</span>
|
||||
</header>
|
||||
<div className="ta-create-grid">
|
||||
<label className="ta-field">
|
||||
<span className="ta-field-label">
|
||||
任务名称<i className="ta-required">*</i>
|
||||
</span>
|
||||
<Input
|
||||
clearable
|
||||
maxlength={40}
|
||||
placeholder="例如:东南亚舆论环境监测"
|
||||
value={name}
|
||||
onChange={(value) => setName(String(value ?? ""))}
|
||||
/>
|
||||
</label>
|
||||
<label className="ta-field">
|
||||
<span className="ta-field-label">对方任务号(可选)</span>
|
||||
<Input
|
||||
clearable
|
||||
maxlength={32}
|
||||
placeholder="留空自动生成"
|
||||
value={sourceTaskId}
|
||||
onChange={(value) => setSourceTaskId(String(value ?? ""))}
|
||||
/>
|
||||
</label>
|
||||
<label className="ta-field ta-field--wide">
|
||||
<span className="ta-field-label">任务简介</span>
|
||||
<Textarea
|
||||
autosize={{ minRows: 2, maxRows: 4 }}
|
||||
maxlength={200}
|
||||
placeholder="一句话说明任务背景与分析目标,定时执行时作为任务上下文注入各智能体"
|
||||
value={description}
|
||||
onChange={(value) => setDescription(String(value ?? ""))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ta-binding-section">
|
||||
<header className="ta-binding-section-head">
|
||||
<h4>执行设置</h4>
|
||||
<span>{describeScheduleConfig(schedule)} 自动执行</span>
|
||||
</header>
|
||||
<div className="ta-binding-settings">
|
||||
<div className="ta-field">
|
||||
<span className="ta-field-label">执行频率</span>
|
||||
<SchedulePicker value={schedule} onChange={setSchedule} />
|
||||
</div>
|
||||
<label className="ta-field">
|
||||
<span className="ta-field-label">总控提示(可选)</span>
|
||||
<Textarea
|
||||
autosize={{ minRows: 2, maxRows: 4 }}
|
||||
maxlength={500}
|
||||
placeholder="写给总控(协调者)的编排要求;问答沉淀的「方向备注」也会注入后续执行"
|
||||
value={coordinatorPrompt}
|
||||
onChange={(value) => setCoordinatorPrompt(String(value ?? ""))}
|
||||
/>
|
||||
</label>
|
||||
<div className="ta-field ta-field--switch">
|
||||
<span className="ta-field-label">创建后立即启用</span>
|
||||
<Switch value={enabled} onChange={(value) => setEnabled(!!value)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="ta-binding-section">
|
||||
<header className="ta-binding-section-head">
|
||||
<h4>选择业务链</h4>
|
||||
<span>定时执行按业务链的多智能体关系(同阶段并行 · 阶段间串行)运行</span>
|
||||
</header>
|
||||
{chainsLoading ? (
|
||||
<p className="ta-binding-tip">正在加载业务链…</p>
|
||||
) : (
|
||||
<div className="ta-chain-grid">
|
||||
{chains.map((chain) => {
|
||||
const active = chain.id === chainId;
|
||||
return (
|
||||
<button
|
||||
className={`ta-chain-card${active ? " is-active" : ""}`}
|
||||
key={chain.id}
|
||||
onClick={() => setChainId(chain.id)}
|
||||
type="button"
|
||||
>
|
||||
<header>
|
||||
<Radio aria-hidden="true" size={14} />
|
||||
<span className="ta-chain-card-title">{chain.title}</span>
|
||||
<em>
|
||||
{chain.seats.length} 智能体 · {chainStages(chain).length} 阶段
|
||||
</em>
|
||||
</header>
|
||||
{chain.description && <p>{chain.description}</p>}
|
||||
<div className="ta-chain-card-flow">
|
||||
<AgentFlow
|
||||
seats={chainSeats(chain) as MatSeat[]}
|
||||
size="sm"
|
||||
stages={chainStages(chain)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 席位执行过程抽屉:点击流程图节点打开,展示该智能体在某次运行里的完整过程
|
||||
* (输入 → AI 分析 → 工具调用 → 产物块)。后端就绪后由 runs/{id}/messages?seat= 提供数据。
|
||||
*/
|
||||
|
||||
import { ArrowDownRight, Bot, Wrench } from "lucide-react";
|
||||
import { Drawer, Tag } from "tdesign-react";
|
||||
|
||||
import { seatColorIndex } from "@/pages/official-multi-agent-tasks/utils";
|
||||
import { buildSeatProcess } from "../store";
|
||||
import type { TaRunView, TaSeatState, TaTask } from "../types";
|
||||
|
||||
const STATE_THEME: Record<TaSeatState, "success" | "warning" | "danger" | "default"> = {
|
||||
done: "success",
|
||||
running: "warning",
|
||||
failed: "danger",
|
||||
waiting: "default",
|
||||
};
|
||||
|
||||
const STATE_TEXT: Record<TaSeatState, string> = {
|
||||
waiting: "等待执行",
|
||||
running: "执行中",
|
||||
done: "已完成",
|
||||
failed: "执行失败",
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
input: "输入",
|
||||
ai: "分析",
|
||||
tool: "工具",
|
||||
artifact: "产物",
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
||||
const seconds = Math.round(ms / 1000);
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
|
||||
}
|
||||
|
||||
export default function SeatProcessDrawer({
|
||||
run,
|
||||
seatAgentId,
|
||||
task,
|
||||
onClose,
|
||||
}: {
|
||||
run: TaRunView | null;
|
||||
seatAgentId: string | null;
|
||||
task: TaTask | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const visible = !!run && !!seatAgentId;
|
||||
const process =
|
||||
visible && run ? buildSeatProcess(run, seatAgentId!, task) : null;
|
||||
const info = run?.seatInfos[seatAgentId ?? ""] ?? null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
className="ta-seat-drawer"
|
||||
closeBtn
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
header={
|
||||
process ? (
|
||||
<div className="ta-seat-header">
|
||||
<span className={`ta-seat-glyph ta-seat--c${seatColorIndex(seatAgentId ?? "")}`} aria-hidden="true">
|
||||
<Bot size={18} />
|
||||
</span>
|
||||
<div className="ta-seat-heading">
|
||||
<h3>{process.seatName}</h3>
|
||||
<p>执行过程 · {run?.trigger === "manual" ? "手动执行(总控问答)" : "定时执行"}</p>
|
||||
</div>
|
||||
{info && (
|
||||
<Tag size="small" theme={STATE_THEME[info.state]} variant="light">
|
||||
{STATE_TEXT[info.state]}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
placement="right"
|
||||
size="520px"
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
>
|
||||
{process && (
|
||||
<div className="ta-seat-process">
|
||||
{info && (
|
||||
<div className="ta-seat-meta">
|
||||
<span>状态:{STATE_TEXT[info.state]}</span>
|
||||
<span>耗时:{formatDuration(info.durationMs)}</span>
|
||||
<span>所属阶段:第 {(run?.stages.findIndex((stage) => stage.includes(seatAgentId ?? "")) ?? -1) + 1} 阶段</span>
|
||||
</div>
|
||||
)}
|
||||
<ol className="ta-seat-timeline">
|
||||
{process.messages.map((message, index) => (
|
||||
<li className={`ta-seat-item is-${message.kind}`} key={index}>
|
||||
<span className="ta-seat-item-marker">
|
||||
{message.kind === "tool" ? (
|
||||
<Wrench size={11} />
|
||||
) : (
|
||||
<ArrowDownRight size={11} />
|
||||
)}
|
||||
</span>
|
||||
<div className="ta-seat-item-body">
|
||||
<header>
|
||||
<em className={`ta-seat-item-kind is-${message.kind}`}>
|
||||
{KIND_LABEL[message.kind] ?? message.kind}
|
||||
</em>
|
||||
<span className="ta-seat-item-title">{message.title}</span>
|
||||
</header>
|
||||
<p>{message.content}</p>
|
||||
{message.detail && <pre>{message.detail}</pre>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { TaRunView, TaTask } from "./types";
|
||||
|
||||
export type { TaRunView, TaTask };
|
||||
export type TaTaskViewLike = { task: TaTask; runs: TaRunView[] };
|
||||
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* 外部任务定时分析的本地 mock 存储层(与 multi-agent-tasks 同款模式)。
|
||||
*
|
||||
* - 任务/绑定/运行记录落 localStorage;「立即执行 / 模拟手动执行」写入运行记录,
|
||||
* 状态由 startedAt + durationMs 按当前时间推导(排队 → 按阶段推进 → 成功/失败),
|
||||
* 配合 React Query 轮询获得无后端的"实时"流程图推进。
|
||||
* - 席位执行过程消息在读取侧按席位生成(buildSeatProcess),不入库。
|
||||
* - 对外接口(创建/删除/详情时间线/问答)就绪后替换本层。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { buildCron } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
import { describeScheduleConfig } from "@/pages/official-multi-agent-tasks/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
import type {
|
||||
TaBlockMapping,
|
||||
TaBinding,
|
||||
TaBindingPayload,
|
||||
TaRunRecord,
|
||||
TaRunStatus,
|
||||
TaRunView,
|
||||
TaSeatRunInfo,
|
||||
TaSeatState,
|
||||
TaTask,
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "deerflow.official.task-analysis.v2";
|
||||
|
||||
interface TaStore {
|
||||
tasks: TaTask[];
|
||||
runs: TaRunRecord[];
|
||||
/** 上次绑定时选择的业务链 id(创建接口响应里的 default_chain_id 记忆)。 */
|
||||
lastChainId: string | null;
|
||||
}
|
||||
|
||||
const PENDING_MS = 2_000;
|
||||
const STAGE_MS = 6_000;
|
||||
|
||||
/** 对方系统定义的详情块类型(mock 固定清单;接接口后由创建方传入)。 */
|
||||
export const TA_BLOCK_TYPES: Array<{ key: string; name: string }> = [
|
||||
{ key: "background", name: "任务背景" },
|
||||
{ key: "dynamics", name: "动态分析" },
|
||||
{ key: "risk", name: "风险研判" },
|
||||
{ key: "action", name: "行动建议" },
|
||||
{ key: "conclusion", name: "结论汇总" },
|
||||
];
|
||||
|
||||
function readStore(): TaStore {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<TaStore>;
|
||||
if (Array.isArray(parsed.tasks)) {
|
||||
return {
|
||||
tasks: parsed.tasks as TaTask[],
|
||||
runs: parsed.runs ?? [],
|
||||
lastChainId: parsed.lastChainId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 解析失败按首次访问处理。
|
||||
}
|
||||
const seeded = seedStore();
|
||||
writeStore(seeded);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function writeStore(store: TaStore) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// 存储被禁用时静默降级为内存态。
|
||||
}
|
||||
}
|
||||
|
||||
function mutateStore(mutator: (store: TaStore) => TaStore): TaStore {
|
||||
const next = mutator(readStore());
|
||||
writeStore(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function hoursAgo(hours: number): string {
|
||||
return new Date(Date.now() - hours * 3_600_000).toISOString();
|
||||
}
|
||||
|
||||
function seedStore(): TaStore {
|
||||
const seats = {
|
||||
collector: "全网舆情采集",
|
||||
analyst: "舆情研判分析",
|
||||
writer: "报告撰写助手",
|
||||
verifier: "数据核查员",
|
||||
forecaster: "趋势预测专家",
|
||||
};
|
||||
|
||||
const tasks: TaTask[] = [
|
||||
{
|
||||
id: "ta-task-1",
|
||||
sourceTaskId: "TS-2026-0147",
|
||||
name: "东南亚舆论环境监测",
|
||||
description: "对方系统推送的任务:持续跟踪东南亚重点渠道舆论动向,输出结构化分析详情。",
|
||||
binding: {
|
||||
chainId: "seed-chain-std",
|
||||
chainTitle: "标准舆情分析链",
|
||||
cron: "0 9 * * *",
|
||||
cronText: "每天 09:00",
|
||||
enabled: true,
|
||||
blocks: [
|
||||
{ key: "background", name: "任务背景", seatAgentId: "collector" },
|
||||
{ key: "dynamics", name: "动态分析", seatAgentId: "collector" },
|
||||
{ key: "risk", name: "风险研判", seatAgentId: "analyst" },
|
||||
{ key: "action", name: "行动建议", seatAgentId: "analyst" },
|
||||
{ key: "conclusion", name: "结论汇总", seatAgentId: "writer" },
|
||||
],
|
||||
coordinatorPrompt: "采集阶段允许独立检索;研判阶段需引用采集阶段的原始线索。",
|
||||
directionNotes: ["重点关注宗教节日前后的舆论波动。"],
|
||||
timezone: "Asia/Shanghai",
|
||||
},
|
||||
createdAt: hoursAgo(24 * 12),
|
||||
},
|
||||
{
|
||||
id: "ta-task-2",
|
||||
sourceTaskId: "TS-2026-0152",
|
||||
name: "竞品新品发布追踪",
|
||||
description: "追踪 5 家核心竞品的新品发布与市场反应,按块更新详情内容。",
|
||||
binding: {
|
||||
chainId: "seed-chain-single",
|
||||
chainTitle: "单阶段并行分析链",
|
||||
cron: "30 8 * * 1",
|
||||
cronText: "每周一 08:30",
|
||||
enabled: false,
|
||||
blocks: [
|
||||
{ key: "background", name: "任务背景", seatAgentId: "verifier" },
|
||||
{ key: "dynamics", name: "动态分析", seatAgentId: "collector" },
|
||||
{ key: "conclusion", name: "结论汇总", seatAgentId: "writer" },
|
||||
],
|
||||
directionNotes: [],
|
||||
timezone: "Asia/Shanghai",
|
||||
},
|
||||
createdAt: hoursAgo(24 * 8),
|
||||
},
|
||||
{
|
||||
id: "ta-task-3",
|
||||
sourceTaskId: "TS-2026-0160",
|
||||
name: "行业政策周报编制",
|
||||
description: "对方系统刚创建的任务,尚未配置分析方案。",
|
||||
binding: null,
|
||||
createdAt: hoursAgo(5),
|
||||
},
|
||||
];
|
||||
|
||||
const stdStages = [
|
||||
["collector", "verifier"],
|
||||
["analyst"],
|
||||
["writer"],
|
||||
];
|
||||
const singleStages = [["collector", "verifier", "analyst"]];
|
||||
const stdNames = seats;
|
||||
|
||||
const runs: TaRunRecord[] = [
|
||||
{
|
||||
id: uuid() as string,
|
||||
taskId: "ta-task-1",
|
||||
trigger: "schedule",
|
||||
scheduledFor: hoursAgo(2.1),
|
||||
startedAt: hoursAgo(2.1),
|
||||
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
|
||||
stages: stdStages,
|
||||
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
|
||||
seatNames: stdNames,
|
||||
failStage: null,
|
||||
},
|
||||
{
|
||||
id: uuid() as string,
|
||||
taskId: "ta-task-1",
|
||||
trigger: "manual",
|
||||
scheduledFor: hoursAgo(20),
|
||||
startedAt: hoursAgo(20),
|
||||
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
|
||||
stages: stdStages,
|
||||
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
|
||||
seatNames: stdNames,
|
||||
failStage: null,
|
||||
manualSummary: "总控问答:要求研判阶段补充跨平台对比维度,已确认应用到后续执行。",
|
||||
},
|
||||
{
|
||||
id: uuid() as string,
|
||||
taskId: "ta-task-1",
|
||||
trigger: "schedule",
|
||||
scheduledFor: hoursAgo(26),
|
||||
startedAt: hoursAgo(26),
|
||||
durationMs: PENDING_MS + stdStages.length * STAGE_MS,
|
||||
stages: stdStages,
|
||||
stageGoals: ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"],
|
||||
seatNames: stdNames,
|
||||
failStage: 1,
|
||||
},
|
||||
{
|
||||
id: uuid() as string,
|
||||
taskId: "ta-task-2",
|
||||
trigger: "schedule",
|
||||
scheduledFor: hoursAgo(24 * 3),
|
||||
startedAt: hoursAgo(24 * 3),
|
||||
durationMs: PENDING_MS + singleStages.length * STAGE_MS,
|
||||
stages: singleStages,
|
||||
stageGoals: ["三席并行:采集 / 核查 / 研判同时产出"],
|
||||
seatNames: stdNames,
|
||||
failStage: null,
|
||||
},
|
||||
];
|
||||
|
||||
return { tasks, runs, lastChainId: "seed-chain-std" };
|
||||
}
|
||||
|
||||
/** 由记录 + 当前时间推导运行视图:各席位状态/耗时、块产出状态。 */
|
||||
function resolveRunView(run: TaRunRecord, task: TaTask | undefined, now = Date.now()): TaRunView {
|
||||
const startedAt = new Date(run.startedAt).getTime();
|
||||
const elapsed = now - startedAt;
|
||||
const stageTotal = Math.max(1, run.stages.length);
|
||||
const duration = run.durationMs || PENDING_MS + stageTotal * STAGE_MS;
|
||||
|
||||
const buildSeatInfos = (
|
||||
status: TaRunStatus,
|
||||
activeStage: number,
|
||||
): Record<string, TaSeatRunInfo> => {
|
||||
const infos: Record<string, TaSeatRunInfo> = {};
|
||||
run.stages.forEach((stage, index) => {
|
||||
const stageStart = PENDING_MS + index * STAGE_MS;
|
||||
const stageEnd = stageStart + STAGE_MS;
|
||||
const finished = status === "succeeded" || (status === "failed" && index < activeStage);
|
||||
const active = !finished && index === activeStage && status !== "pending";
|
||||
const state: TaSeatState = finished
|
||||
? "done"
|
||||
: active
|
||||
? status === "failed"
|
||||
? "failed"
|
||||
: "running"
|
||||
: "waiting";
|
||||
const durationMs = finished
|
||||
? Math.min(elapsed - stageStart, STAGE_MS)
|
||||
: active
|
||||
? Math.min(elapsed - stageStart, STAGE_MS)
|
||||
: 0;
|
||||
stage.forEach((agentId) => {
|
||||
infos[agentId] = { state, durationMs };
|
||||
});
|
||||
});
|
||||
return infos;
|
||||
};
|
||||
|
||||
let status: TaRunStatus;
|
||||
let stageIndex: number;
|
||||
let finishedAt: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
if (elapsed < PENDING_MS) {
|
||||
status = "pending";
|
||||
stageIndex = 0;
|
||||
} else {
|
||||
const progress = Math.min(elapsed - PENDING_MS, duration - PENDING_MS);
|
||||
stageIndex = Math.min(Math.floor(progress / STAGE_MS), stageTotal - 1);
|
||||
if (run.failStage !== null && stageIndex >= run.failStage) {
|
||||
status = "failed";
|
||||
stageIndex = run.failStage;
|
||||
finishedAt = new Date(startedAt + PENDING_MS + (run.failStage + 1) * STAGE_MS).toISOString();
|
||||
const failSeat = run.stages[run.failStage]?.[0];
|
||||
error = `阶段 ${run.failStage + 1} 执行失败:${run.seatNames[failSeat ?? ""] ?? "席位"} 模拟执行异常`;
|
||||
} else if (elapsed >= duration) {
|
||||
status = "succeeded";
|
||||
stageIndex = stageTotal - 1;
|
||||
finishedAt = new Date(startedAt + duration).toISOString();
|
||||
} else {
|
||||
status = "running";
|
||||
}
|
||||
}
|
||||
|
||||
const seatInfos = buildSeatInfos(status, status === "pending" ? -1 : stageIndex);
|
||||
|
||||
const blockStates = (task?.binding?.blocks ?? []).map((block) => ({
|
||||
key: block.key,
|
||||
name: block.name,
|
||||
state: seatInfos[block.seatAgentId]?.state ?? "waiting",
|
||||
}));
|
||||
|
||||
return {
|
||||
...run,
|
||||
finishedAt,
|
||||
status,
|
||||
stageIndex,
|
||||
stageTotal,
|
||||
seatInfos,
|
||||
error,
|
||||
blockStates,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskViews(now = Date.now()): Array<{ task: TaTask; runs: TaRunView[] }> {
|
||||
const store = readStore();
|
||||
return store.tasks.map((task) => {
|
||||
const runs = store.runs
|
||||
.filter((run) => run.taskId === task.id)
|
||||
.map((run) => resolveRunView(run, task, now))
|
||||
.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
||||
return { task, runs };
|
||||
});
|
||||
}
|
||||
|
||||
function anyRunLive(views: Array<{ runs: TaRunView[] }> | undefined): boolean {
|
||||
return !!views?.some(({ runs }) =>
|
||||
runs.some((run) => run.status === "pending" || run.status === "running"),
|
||||
);
|
||||
}
|
||||
|
||||
/** 列表 + 每任务运行视图;运行中 5 秒轮询(设计定稿:流程图每 5 秒刷新)。 */
|
||||
export function useTaTasks() {
|
||||
const query = useQuery({
|
||||
queryKey: ["ta", "tasks"],
|
||||
queryFn: () => buildTaskViews(),
|
||||
refetchInterval: (q) =>
|
||||
anyRunLive(q.state.data as Array<{ runs: TaRunView[] }> | undefined) ? 5_000 : false,
|
||||
});
|
||||
const views = query.data ?? [];
|
||||
return { views, tasks: views.map((view) => view.task), isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
export function useTaTaskRuns(taskId: string | null) {
|
||||
const query = useQuery({
|
||||
queryKey: ["ta", "tasks", taskId],
|
||||
queryFn: () => {
|
||||
const view = buildTaskViews().find((item) => item.task.id === taskId);
|
||||
return view?.runs ?? [];
|
||||
},
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (q) => {
|
||||
const runs = q.state.data as TaRunView[] | undefined;
|
||||
const live = !!runs?.some((run) => run.status === "pending" || run.status === "running");
|
||||
return live ? 5_000 : false;
|
||||
},
|
||||
});
|
||||
return { runs: query.data ?? [], isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
export function useTaTaskActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["ta"] });
|
||||
|
||||
/** 页面手动创建外部任务(名称必填;对方任务号留空则自动生成)。 */
|
||||
const createTask = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
name: string;
|
||||
sourceTaskId?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const task: TaTask = {
|
||||
id: uuid() as string,
|
||||
sourceTaskId:
|
||||
payload.sourceTaskId?.trim() ||
|
||||
`TS-${new Date().getFullYear()}-${String(Date.now()).slice(-5)}`,
|
||||
name: payload.name.trim(),
|
||||
description: payload.description?.trim() || "",
|
||||
binding: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
mutateStore((store) => ({ ...store, tasks: [task, ...store.tasks] }));
|
||||
return task;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
/** 绑定 / 修改绑定(记录 lastChainId 供创建接口返回默认项)。 */
|
||||
const bind = useMutation({
|
||||
mutationFn: async ({ taskId, payload }: { taskId: string; payload: TaBindingPayload }) => {
|
||||
const cron = buildCron(payload.schedule);
|
||||
mutateStore((store) => ({
|
||||
...store,
|
||||
lastChainId: payload.chainId,
|
||||
tasks: store.tasks.map((task) =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
binding: {
|
||||
chainId: payload.chainId,
|
||||
chainTitle: payload.chainTitle,
|
||||
cron,
|
||||
cronText: describeScheduleConfig(payload.schedule),
|
||||
enabled: payload.enabled,
|
||||
blocks: payload.blocks ?? task.binding?.blocks ?? [],
|
||||
coordinatorPrompt: payload.coordinatorPrompt,
|
||||
directionNotes: task.binding?.directionNotes ?? [],
|
||||
timezone: task.binding?.timezone ?? "Asia/Shanghai",
|
||||
} satisfies TaBinding,
|
||||
}
|
||||
: task,
|
||||
),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const unbind = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
mutateStore((store) => ({
|
||||
...store,
|
||||
tasks: store.tasks.map((task) =>
|
||||
task.id === taskId ? { ...task, binding: null } : task,
|
||||
),
|
||||
runs: store.runs.filter((run) => run.taskId !== taskId),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const setEnabled = useMutation({
|
||||
mutationFn: async ({ taskId, enabled }: { taskId: string; enabled: boolean }) => {
|
||||
mutateStore((store) => ({
|
||||
...store,
|
||||
tasks: store.tasks.map((task) =>
|
||||
task.id === taskId && task.binding
|
||||
? { ...task, binding: { ...task.binding, enabled } }
|
||||
: task,
|
||||
),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
/** 删除外部任务(对应对外删除接口:任务 + 绑定 + 执行记录彻底删除)。 */
|
||||
const removeTask = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
mutateStore((store) => ({
|
||||
...store,
|
||||
tasks: store.tasks.filter((task) => task.id !== taskId),
|
||||
runs: store.runs.filter((run) => run.taskId !== taskId),
|
||||
}));
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
/** 立即触发一次定时执行(schedule)。 */
|
||||
const triggerRun = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
const store = readStore();
|
||||
const task = store.tasks.find((item) => item.id === taskId);
|
||||
const binding = task?.binding;
|
||||
if (!task || !binding) throw new Error("任务未绑定分析方案");
|
||||
const stages = chainStagesOf(binding, store);
|
||||
const now = new Date().toISOString();
|
||||
const run: TaRunRecord = {
|
||||
id: uuid() as string,
|
||||
taskId,
|
||||
trigger: "schedule",
|
||||
scheduledFor: now,
|
||||
startedAt: now,
|
||||
durationMs: PENDING_MS + stages.length * STAGE_MS,
|
||||
stages,
|
||||
stageGoals: stages.map((_, index) => stageGoalOf(index)),
|
||||
seatNames: Object.fromEntries(stages.flat().map((id) => [id, seatDisplayName(id)])),
|
||||
failStage: null,
|
||||
};
|
||||
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
|
||||
return run;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
/** 模拟一次手动执行(对方详情页总控问答产生)。 */
|
||||
const triggerManualRun = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
const store = readStore();
|
||||
const task = store.tasks.find((item) => item.id === taskId);
|
||||
const binding = task?.binding;
|
||||
if (!task || !binding) throw new Error("任务未绑定分析方案");
|
||||
const stages = chainStagesOf(binding, store);
|
||||
const now = new Date().toISOString();
|
||||
const run: TaRunRecord = {
|
||||
id: uuid() as string,
|
||||
taskId,
|
||||
trigger: "manual",
|
||||
scheduledFor: now,
|
||||
startedAt: now,
|
||||
durationMs: PENDING_MS + stages.length * STAGE_MS,
|
||||
stages,
|
||||
stageGoals: stages.map((_, index) => stageGoalOf(index)),
|
||||
seatNames: Object.fromEntries(stages.flat().map((id) => [id, seatDisplayName(id)])),
|
||||
failStage: null,
|
||||
manualSummary: "总控问答:按对方详情页提问,总控给对应席位派活并汇总结论。",
|
||||
};
|
||||
mutateStore((current) => ({ ...current, runs: [run, ...current.runs] }));
|
||||
return run;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { createTask, bind, unbind, setEnabled, removeTask, triggerRun, triggerManualRun };
|
||||
}
|
||||
|
||||
/** 绑定链的编排(种子任务用内置编排;真实业务链按其 stages 展开,null = 线性)。 */
|
||||
export function chainStagesOf(
|
||||
binding: Pick<TaBinding, "chainId">,
|
||||
store: TaStore = readStore(),
|
||||
): string[][] {
|
||||
if (binding.chainId === "seed-chain-std") {
|
||||
return [
|
||||
["collector", "verifier"],
|
||||
["analyst"],
|
||||
["writer"],
|
||||
];
|
||||
}
|
||||
if (binding.chainId === "seed-chain-single") {
|
||||
return [["collector", "verifier", "analyst"]];
|
||||
}
|
||||
// 真实业务链的编排由页面侧传入的 chain 数据展开;存储层兜底为单席位串行。
|
||||
const seats = seedSeatsOf(store);
|
||||
return seats.map((agentId) => [agentId]);
|
||||
}
|
||||
|
||||
function seedSeatsOf(_store: TaStore): string[] {
|
||||
return ["collector", "verifier", "analyst", "writer"];
|
||||
}
|
||||
|
||||
/** 演示用的阶段目标(seed 链固定;真实链由绑定快照提供)。 */
|
||||
function stageGoalOf(stageIndex: number): string | null {
|
||||
const goals = ["并行完成全网采集与数据核查", "研判风险与行动方向", "汇总各块结论"];
|
||||
return goals[stageIndex] ?? null;
|
||||
}
|
||||
|
||||
export function seatDisplayName(agentId: string): string {
|
||||
const names: Record<string, string> = {
|
||||
collector: "全网舆情采集",
|
||||
verifier: "数据核查员",
|
||||
analyst: "舆情研判分析",
|
||||
writer: "报告撰写助手",
|
||||
forecaster: "趋势预测专家",
|
||||
};
|
||||
return names[agentId] ?? agentId;
|
||||
}
|
||||
|
||||
// ── 席位执行过程(读取侧生成 mock)────────────────────────────────────────
|
||||
|
||||
export interface TaSeatMessage {
|
||||
kind: "input" | "ai" | "tool" | "artifact";
|
||||
title: string;
|
||||
content: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** 生成某席位在某次运行里的执行过程消息(后端就绪后换 runs/{id}/messages?seat=)。 */
|
||||
export function buildSeatProcess(
|
||||
run: TaRunView,
|
||||
seatAgentId: string,
|
||||
task: TaTask | undefined,
|
||||
): { seatName: string; messages: TaSeatMessage[]; blocks: TaBlockMapping[] } {
|
||||
const seatName = run.seatNames[seatAgentId] ?? seatAgentId;
|
||||
const info = run.seatInfos[seatAgentId] ?? { state: "waiting" as TaSeatState, durationMs: 0 };
|
||||
const stageIndex = run.stages.findIndex((stage) => stage.includes(seatAgentId));
|
||||
const blocks = (task?.binding?.blocks ?? []).filter(
|
||||
(block) => block.seatAgentId === seatAgentId,
|
||||
);
|
||||
|
||||
const messages: TaSeatMessage[] = [
|
||||
{
|
||||
kind: "input",
|
||||
title: "输入 · 任务上下文",
|
||||
content:
|
||||
`${task?.name ?? ""}(对方任务 ${task?.sourceTaskId ?? "-"})\n` +
|
||||
`${task?.description ?? ""}\n\n` +
|
||||
(blocks.length
|
||||
? `本席位负责产出:${blocks.map((block) => block.name).join("、")}。`
|
||||
: "按业务链席位职责执行。") +
|
||||
(stageIndex > 0
|
||||
? `\n上一阶段产出摘要:${run.stages[stageIndex - 1]
|
||||
?.map((id) => run.seatNames[id])
|
||||
.join("、")} 的中间结论已注入。`
|
||||
: ""),
|
||||
},
|
||||
];
|
||||
|
||||
if (info.state === "waiting") {
|
||||
return { seatName, messages, blocks };
|
||||
}
|
||||
|
||||
messages.push({
|
||||
kind: "ai",
|
||||
title: "AI 分析",
|
||||
content:
|
||||
`(${seatName} 的分析过程 · 演示内容)\n` +
|
||||
`围绕任务简介拆解检索方向,交叉核对来源可信度,形成本席位负责块的结构化结论。`,
|
||||
});
|
||||
|
||||
messages.push({
|
||||
kind: "tool",
|
||||
title: "工具调用 · web_search",
|
||||
content: "query=\"任务关键词 最新动态\" → 命中 12 条,采纳 7 条(含来源与时间)",
|
||||
detail:
|
||||
'{"results":[{"title":"来源A:行业动态","snippet":"……"},{"title":"来源B:政策更新","snippet":"……"}]}',
|
||||
});
|
||||
|
||||
if (info.state === "done") {
|
||||
blocks.forEach((block) => {
|
||||
messages.push({
|
||||
kind: "artifact",
|
||||
title: `产出 · ${block.name}`,
|
||||
content: `(${block.name} 块内容摘要 · 演示)已按对方系统约定的结构生成,随本次执行写入时间线版本。`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { seatName, messages, blocks };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 外部任务多智能体定时分析 —— 前端先行数据模型(后端接口就绪后替换 store 层)。
|
||||
*
|
||||
* 对应设计定稿:
|
||||
* - 外部任务(对方系统推送创建/删除)1:1 绑定「业务链 + 频率 + 块→席位映射」。
|
||||
* - 执行记录两类:schedule 定时执行 / manual 手动执行(对方详情页总控问答)。
|
||||
* - 时间线内容版本:每次定时执行产出各块内容(供对方详情接口拉取),管理页内
|
||||
* 在席位执行过程抽屉里预览。
|
||||
*/
|
||||
|
||||
import type { ScheduleConfig } from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
|
||||
export type TaRunTrigger = "schedule" | "manual";
|
||||
export type TaRunStatus = "pending" | "running" | "succeeded" | "failed";
|
||||
export type TaSeatState = "waiting" | "running" | "done" | "failed";
|
||||
|
||||
/** 对方详情的分块内容与业务链席位的映射(存在绑定上,业务链保持纯粹)。 */
|
||||
export interface TaBlockMapping {
|
||||
/** 块类型 key(对方系统定义)。 */
|
||||
key: string;
|
||||
/** 块展示名。 */
|
||||
name: string;
|
||||
/** 产出该块的席位(业务链 agent_id)。 */
|
||||
seatAgentId: string;
|
||||
}
|
||||
|
||||
export interface TaBinding {
|
||||
chainId: string;
|
||||
/** 业务链标题快照(展示用,链被删后仍可读)。 */
|
||||
chainTitle: string;
|
||||
cron: string;
|
||||
cronText: string;
|
||||
enabled: boolean;
|
||||
blocks: TaBlockMapping[];
|
||||
coordinatorPrompt?: string;
|
||||
/** 问答沉淀的「执行方向备注」,注入后续每次定时执行(累积追加)。 */
|
||||
directionNotes: string[];
|
||||
/** 绑定时记住,作为下次创建任务的默认业务链返回给对方系统。 */
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface TaTask {
|
||||
id: string;
|
||||
/** 对方系统任务 id。 */
|
||||
sourceTaskId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
binding: TaBinding | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 落库的运行记录;状态由 startedAt + durationMs 在读取时按当前时间推导。 */
|
||||
export interface TaRunRecord {
|
||||
id: string;
|
||||
taskId: string;
|
||||
trigger: TaRunTrigger;
|
||||
scheduledFor: string;
|
||||
startedAt: string;
|
||||
durationMs: number;
|
||||
/** 该次执行的业务链编排快照(席位/阶段),链后续修改不影响历史回放。 */
|
||||
stages: string[][];
|
||||
/** 各阶段目标快照(与 stages 同下标),供流程图连线标签展示。 */
|
||||
stageGoals?: (string | null)[] | null;
|
||||
seatNames: Record<string, string>;
|
||||
/** 在该阶段(下标)失败;null = 成功。 */
|
||||
failStage: number | null;
|
||||
/** 手动执行(总控问答)的摘要。 */
|
||||
manualSummary?: string;
|
||||
}
|
||||
|
||||
/** 读取时推导出的运行视图(每席位状态 + 耗时)。 */
|
||||
export interface TaSeatRunInfo {
|
||||
state: TaSeatState;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface TaRunView {
|
||||
id: string;
|
||||
taskId: string;
|
||||
trigger: TaRunTrigger;
|
||||
scheduledFor: string;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
status: TaRunStatus;
|
||||
stageIndex: number;
|
||||
stageTotal: number;
|
||||
seatInfos: Record<string, TaSeatRunInfo>;
|
||||
error: string | null;
|
||||
/** 各块产出状态(按绑定块映射推导;未绑定返回空)。 */
|
||||
blockStates: Array<{ key: string; name: string; state: TaSeatState }>;
|
||||
manualSummary?: string;
|
||||
stages: string[][];
|
||||
stageGoals?: (string | null)[] | null;
|
||||
seatNames: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 绑定弹窗的提交载荷(内容块映射已移除;blocks 仅为兼容旧数据保留)。 */
|
||||
export interface TaBindingPayload {
|
||||
chainId: string;
|
||||
chainTitle: string;
|
||||
schedule: ScheduleConfig;
|
||||
enabled: boolean;
|
||||
blocks?: TaBlockMapping[];
|
||||
coordinatorPrompt?: string;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
|
||||
import { ArrowLeft, ClipboardList, FileText, MessageSquarePlus, Square } from "lucide-react";
|
||||
|
||||
import type { PagePhase } from "@/report-collaboration/state/selectors";
|
||||
|
||||
/** 计划模式覆盖的阶段:第一条消息起到工作流确认执行前。 */
|
||||
const PLAN_MODE_PHASES = new Set<PagePhase>(["clarifying", "planning", "proposal_ready"]);
|
||||
|
||||
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
|
||||
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
|
||||
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
|
||||
@@ -51,6 +54,16 @@ export function ReportCollaborationHeader({
|
||||
<span className="rc-header-title" title={title}>
|
||||
报告协作 / {title}
|
||||
</span>
|
||||
{PLAN_MODE_PHASES.has(phase) ? (
|
||||
<span
|
||||
className="rc-chip"
|
||||
title="规划师正在梳理需求并规划工作流,确认后开始执行"
|
||||
style={{ background: "rgba(37, 99, 235, 0.1)", color: "var(--rc-accent)" }}
|
||||
>
|
||||
<ClipboardList className="size-3" aria-hidden />
|
||||
计划模式
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
|
||||
{badge.label}
|
||||
</span>
|
||||
|
||||
@@ -156,8 +156,23 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!plan) return [];
|
||||
return plan.edges.map((edge, index) => {
|
||||
const targetState = nodeStates[edge.target];
|
||||
const active = targetState?.status === "running" || targetState?.status === "validating";
|
||||
// 边语义 = 数据正在流入 target 节点:target 执行中 → 流动动画;
|
||||
// 失败红虚线 / 等待橙 / 已被替代降灰,其余静态。
|
||||
const targetStatus = nodeStates[edge.target]?.status;
|
||||
let className = "rc-edge";
|
||||
let stroke = "var(--rc-flow-edge)";
|
||||
if (targetStatus === "running" || targetStatus === "validating") {
|
||||
className = "rc-edge-active";
|
||||
stroke = "var(--rc-running)";
|
||||
} else if (targetStatus === "failed") {
|
||||
className = "rc-edge-failed";
|
||||
stroke = "var(--rc-danger)";
|
||||
} else if (targetStatus === "awaiting_input") {
|
||||
className = "rc-edge-waiting";
|
||||
stroke = "var(--rc-waiting)";
|
||||
} else if (targetStatus === "superseded" || targetStatus === "interrupted" || targetStatus === "cancelled") {
|
||||
className = "rc-edge-dimmed";
|
||||
}
|
||||
return {
|
||||
id: `${edge.source}->${edge.target}-${index}`,
|
||||
source: edge.source,
|
||||
@@ -165,11 +180,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
|
||||
label: edge.label ?? undefined,
|
||||
type: "default",
|
||||
animated: false,
|
||||
className: active ? "rc-edge-active" : "rc-edge",
|
||||
style: {
|
||||
stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
className,
|
||||
style: { stroke, strokeWidth: 2 },
|
||||
};
|
||||
});
|
||||
}, [plan, nodeStates]);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
|
||||
import type { ReportRequirementSnapshot } from "@/report-collaboration/api/types";
|
||||
|
||||
interface RequirementSummaryCardProps {
|
||||
requirement: ReportRequirementSnapshot;
|
||||
/** 需求仍可修改的提示(生成方案前);方案已出后为 false。 */
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex gap-2 text-[12px] leading-5">
|
||||
<span className="w-16 shrink-0 text-[var(--rc-text-secondary)]">{label}</span>
|
||||
<span className="min-w-0 flex-1 break-words">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 计划模式 · 需求摘要卡:结构化展示规划师确认的需求快照。 */
|
||||
export function RequirementSummaryCard({ requirement, editable = true }: RequirementSummaryCardProps) {
|
||||
const angles = (requirement.required_angles ?? []).filter(Boolean).join("、");
|
||||
return (
|
||||
<div className="rc-card mx-auto w-full max-w-[560px] rounded-xl border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] p-3.5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<ClipboardCheck className="size-4" style={{ color: "var(--rc-accent)" }} aria-hidden />
|
||||
<span className="text-[13px] font-semibold">需求摘要 · v{requirement.revision}</span>
|
||||
<span className="rc-chip ml-auto" style={{ background: "rgba(37,99,235,.1)", color: "var(--rc-accent)" }}>
|
||||
计划模式
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Field label="主题" value={requirement.topic} />
|
||||
<Field label="目的" value={requirement.objective} />
|
||||
<Field label="读者" value={requirement.audience} />
|
||||
<Field label="角度" value={angles || null} />
|
||||
<Field label="时间范围" value={requirement.time_range} />
|
||||
<Field label="地域" value={requirement.geography} />
|
||||
<Field label="篇幅" value={requirement.length_target} />
|
||||
<Field label="语气" value={requirement.tone} />
|
||||
</div>
|
||||
{editable ? (
|
||||
<p className="mt-2.5 border-t border-[var(--rc-border)] pt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
|
||||
我正在基于这份需求为你规划工作流,稍候即可在对话中查看方案;想调整可直接在下方补充。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { toast } from "sonner";
|
||||
import { FileText, Loader2, Sparkles } from "lucide-react";
|
||||
import { FileText, Sparkles } from "lucide-react";
|
||||
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/report-collaboration/components/ReportPreviewDialog";
|
||||
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
|
||||
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
|
||||
import { RequirementSummaryCard } from "@/report-collaboration/components/RequirementSummaryCard";
|
||||
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
|
||||
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
|
||||
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
buildCommandConfirmCard,
|
||||
buildPlanGroupCard,
|
||||
buildReportAvailableCard,
|
||||
buildRequirementReadyCard,
|
||||
readReportCollaborationCard,
|
||||
} from "@/report-collaboration/state/message-adapter";
|
||||
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
|
||||
@@ -247,11 +249,23 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
if (next) setFocusedAgentRunId(next);
|
||||
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
|
||||
|
||||
// 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
|
||||
// 虚拟业务卡:需求摘要(计划模式)/ 方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
|
||||
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
|
||||
const runStarted = Boolean(state.run && state.run.plan_id);
|
||||
const extraCards = useMemo<Message[]>(() => {
|
||||
const cards: Message[] = [];
|
||||
if (
|
||||
state.requirement &&
|
||||
state.plans.length === 0 &&
|
||||
!runStarted &&
|
||||
Object.values(state.messagesById).some(
|
||||
(message) =>
|
||||
message.role === "ai" &&
|
||||
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
|
||||
)
|
||||
) {
|
||||
cards.push(buildRequirementReadyCard(state.requirement));
|
||||
}
|
||||
if (
|
||||
state.plans.length > 0 &&
|
||||
!runStarted &&
|
||||
@@ -341,39 +355,16 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
[sessionId, session],
|
||||
);
|
||||
|
||||
// 澄清完成(协调者已写入需求版本)但还没生成过方案:给出显式「发起规划」入口。
|
||||
const requirementReady = useMemo(
|
||||
() =>
|
||||
Object.values(state.messagesById).some(
|
||||
(message) =>
|
||||
message.role === "ai" &&
|
||||
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
|
||||
),
|
||||
[state.messagesById],
|
||||
);
|
||||
const showPlanRequestEntry =
|
||||
state.session?.status === "clarifying" && state.plans.length === 0 && requirementReady;
|
||||
const [planRequestBusy, setPlanRequestBusy] = useState(false);
|
||||
const handleRequestPlans = useCallback(async () => {
|
||||
setPlanRequestBusy(true);
|
||||
try {
|
||||
await getReportCollaborationDriver().requestPlans(
|
||||
sessionId,
|
||||
state.session?.requirement_revision ?? null,
|
||||
);
|
||||
await session.refreshSnapshot();
|
||||
toast.success("已生成候选方案,可在对话中选择");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPlanRequestBusy(false);
|
||||
}
|
||||
}, [sessionId, session, state.session?.requirement_revision]);
|
||||
// 方案生成由后端在规划师判定 ready 后自动衔接(同一次请求内规划),
|
||||
// 前端无手动「生成候选方案」按钮;此处仅保留摘要卡的注入判定。
|
||||
|
||||
const businessCardSlot = useCallback(
|
||||
(message: Message) => {
|
||||
const card = readReportCollaborationCard(message);
|
||||
if (!card) return null;
|
||||
if (card.kind === "requirement_ready") {
|
||||
return <RequirementSummaryCard requirement={card.requirement} editable={!runStarted} />;
|
||||
}
|
||||
if (card.kind === "plan_group") {
|
||||
return (
|
||||
<ReportPlanCards
|
||||
@@ -414,7 +405,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[previewPlan, previewPlanId, session],
|
||||
[previewPlan, previewPlanId, runStarted, session],
|
||||
);
|
||||
|
||||
const speakerShell = useCallback(
|
||||
@@ -565,43 +556,19 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
|
||||
: undefined
|
||||
}
|
||||
composer={
|
||||
<div className="flex flex-col">
|
||||
{showPlanRequestEntry ? (
|
||||
<div className="mb-1.5 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="rc-btn"
|
||||
style={{
|
||||
background: "var(--rc-accent)",
|
||||
color: "#fff",
|
||||
borderColor: "var(--rc-accent)",
|
||||
}}
|
||||
disabled={planRequestBusy}
|
||||
onClick={() => void handleRequestPlans()}
|
||||
>
|
||||
{planRequestBusy ? (
|
||||
<Loader2 className="rc-spin size-3.5" aria-hidden />
|
||||
) : (
|
||||
<Sparkles className="size-3.5" aria-hidden />
|
||||
)}
|
||||
{planRequestBusy ? "正在生成候选方案…" : "需求已就绪,生成候选方案"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<ReportComposer
|
||||
phase={phase}
|
||||
busy={session.sendBusy}
|
||||
target={composerTarget}
|
||||
onClearTarget={() => setComposerTarget(null)}
|
||||
onSend={(text) =>
|
||||
void session.send(text, {
|
||||
nodeId: composerTarget?.nodeId ?? null,
|
||||
<ReportComposer
|
||||
phase={phase}
|
||||
busy={session.sendBusy}
|
||||
target={composerTarget}
|
||||
onClearTarget={() => setComposerTarget(null)}
|
||||
onSend={(text) =>
|
||||
void session.send(text, {
|
||||
nodeId: composerTarget?.nodeId ?? null,
|
||||
agentRunId: composerTarget?.agentRunId ?? null,
|
||||
})
|
||||
}
|
||||
focusSignal={focusSignal}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
CollaborationCommand,
|
||||
CollaborationMessage,
|
||||
ReportPlanCandidate,
|
||||
ReportRequirementSnapshot,
|
||||
ReportVersion,
|
||||
} from "@/report-collaboration/api/types";
|
||||
|
||||
@@ -113,6 +114,11 @@ export type ReportCollaborationCardPayload =
|
||||
selectedPlanId: string | null;
|
||||
requirementRevision: number;
|
||||
}
|
||||
| {
|
||||
kind: "requirement_ready";
|
||||
requirement: ReportRequirementSnapshot;
|
||||
revision: number;
|
||||
}
|
||||
| {
|
||||
kind: "command_confirm";
|
||||
command: CollaborationCommand;
|
||||
@@ -163,6 +169,17 @@ export function buildPlanGroupCard(
|
||||
});
|
||||
}
|
||||
|
||||
/** 需求摘要卡(计划模式:需求已就绪、尚未生成方案)。 */
|
||||
export function buildRequirementReadyCard(
|
||||
requirement: ReportRequirementSnapshot,
|
||||
): Message {
|
||||
return buildCardMessage(`rc-card-requirement-r${requirement.revision}`, {
|
||||
kind: "requirement_ready",
|
||||
requirement,
|
||||
revision: requirement.revision,
|
||||
});
|
||||
}
|
||||
|
||||
/** 命令确认卡(command.confirmation.required 之后)。 */
|
||||
export function buildCommandConfirmCard(command: CollaborationCommand): Message {
|
||||
return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
|
||||
|
||||
@@ -850,6 +850,21 @@
|
||||
animation: rc-edge-flow 0.5s linear infinite;
|
||||
}
|
||||
|
||||
/* P1.4:失败红虚线 / 等待用户橙色 / 已替代·中断降灰 */
|
||||
.rc-edge-failed .react-flow__edge-path {
|
||||
stroke: var(--rc-danger);
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.rc-edge-waiting .react-flow__edge-path {
|
||||
stroke: var(--rc-waiting);
|
||||
}
|
||||
|
||||
.rc-edge-dimmed .react-flow__edge-path {
|
||||
stroke: var(--rc-flow-edge);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@keyframes rc-edge-flow {
|
||||
from {
|
||||
stroke-dashoffset: 10;
|
||||
|
||||
@@ -39,11 +39,16 @@ export interface ChainFlowStage {
|
||||
goal?: string | null;
|
||||
}
|
||||
|
||||
/** 节点执行状态(任务分析回放传入;缺省展示 Ready)。 */
|
||||
export type ChainFlowSeatState = "waiting" | "running" | "done" | "failed";
|
||||
|
||||
export interface ChainFlowDiagramProps {
|
||||
stages: ChainFlowStage[];
|
||||
/** 点击席位节点卡片(总控节点不触发)。 */
|
||||
onNodeClick?: (agentId: string, name?: string) => void;
|
||||
className?: string;
|
||||
/** 传入时节点卡片叠加执行状态徽标(任务分析的运行回放);不影响原有展示。 */
|
||||
seatStates?: Record<string, { state: ChainFlowSeatState; durationMs?: number }> | null;
|
||||
}
|
||||
|
||||
// ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
|
||||
@@ -84,7 +89,7 @@ interface PlacedNode {
|
||||
rightX: number;
|
||||
}
|
||||
|
||||
export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
|
||||
export function ChainFlowDiagram({ stages, onNodeClick, className, seatStates }: ChainFlowDiagramProps) {
|
||||
// ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
|
||||
const layout = useMemo(() => {
|
||||
// 列:总控 + 各 Stage。
|
||||
@@ -315,15 +320,43 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
|
||||
</svg>
|
||||
|
||||
{/* 节点卡片(参考「业务链条配置器」样式:左彩色描边 + 头像 + 名称 +
|
||||
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。 */}
|
||||
L-阶段徽标 + 同色角色行 + 底部 Ready 心跳条;图标仍用我方 getAvatar)。
|
||||
seatStates 传入时(任务分析回放)叠加执行状态徽标与状态描边。 */}
|
||||
{layout.cols.flatMap((col, colIdx) =>
|
||||
col.nodes.map((n) => {
|
||||
const isCoordinator = n.id === COORDINATOR_ID;
|
||||
const nc = nodeColorOf(n.avatarType);
|
||||
const seatState = !isCoordinator && seatStates ? seatStates[n.id] : undefined;
|
||||
const state = seatState?.state;
|
||||
const stateStyle = state
|
||||
? ({
|
||||
waiting: "text-muted-foreground",
|
||||
running: "text-amber-600 dark:text-amber-300",
|
||||
done: "text-emerald-600 dark:text-emerald-300",
|
||||
failed: "text-red-600 dark:text-red-400",
|
||||
} as const)[state]
|
||||
: null;
|
||||
const stateLabel =
|
||||
state === "waiting"
|
||||
? "等待"
|
||||
: state === "running"
|
||||
? "执行中"
|
||||
: state === "done"
|
||||
? "完成"
|
||||
: state === "failed"
|
||||
? "失败"
|
||||
: null;
|
||||
const stateSeconds = seatState?.durationMs
|
||||
? Math.max(1, Math.round(seatState.durationMs / 1000))
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn("absolute", !isCoordinator && "cursor-pointer")}
|
||||
className={cn(
|
||||
"absolute",
|
||||
!isCoordinator && "cursor-pointer",
|
||||
state === "waiting" && "opacity-60",
|
||||
)}
|
||||
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => {
|
||||
@@ -339,9 +372,15 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
|
||||
isCoordinator
|
||||
? "border-border bg-primary/[0.04]"
|
||||
: "border-border hover:-translate-y-0.5 hover:shadow-[0_8px_22px_rgba(15,23,42,0.10)]",
|
||||
state === "running" && "border-amber-400/80",
|
||||
state === "failed" && "border-red-500/70",
|
||||
)}
|
||||
// 左侧 4px 彩色描边:内联上色,避免被 border-border 的四边色覆盖。
|
||||
style={{ borderLeftColor: nc.hex, borderLeftWidth: 4 }}
|
||||
style={{
|
||||
borderLeftColor: nc.hex,
|
||||
borderLeftWidth: 4,
|
||||
...(state === "running" ? { boxShadow: "0 0 0 3px rgba(245,158,11,0.15)" } : {}),
|
||||
}}
|
||||
>
|
||||
{/* 上:头像 + 名称 + 阶段徽标 + 角色行 */}
|
||||
<div className="flex items-start gap-2 overflow-hidden">
|
||||
@@ -359,13 +398,34 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下:身份 + Ready 心跳条 */}
|
||||
{/* 下:身份 + 状态行(回放模式显示执行状态,否则 Ready 心跳条) */}
|
||||
<div className="flex items-center justify-between border-t border-border/60 pt-1 text-[8px] text-muted-foreground">
|
||||
<span className="truncate">{isCoordinator ? "总控协调核心" : "智能体席位"}</span>
|
||||
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
|
||||
<span className="size-1 rounded-full bg-emerald-500" />
|
||||
Ready
|
||||
</span>
|
||||
{state && stateLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1.5 flex shrink-0 items-center gap-1 rounded px-1 py-0.5 font-medium",
|
||||
stateStyle,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
state === "done" && "bg-emerald-500",
|
||||
state === "running" && "animate-pulse bg-amber-500",
|
||||
state === "failed" && "bg-red-500",
|
||||
state === "waiting" && "bg-muted-foreground/60",
|
||||
)}
|
||||
/>
|
||||
{stateLabel}
|
||||
{stateSeconds != null && state !== "waiting" ? ` · ${stateSeconds}s` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-1.5 flex shrink-0 items-center gap-1 rounded bg-muted px-1 py-0.5 font-medium text-primary">
|
||||
<span className="size-1 rounded-full bg-emerald-500" />
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,22 +505,33 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi
|
||||
style={{ width: mmW, height: mmH }}
|
||||
onClick={onMinimapClick}
|
||||
>
|
||||
{/* 节点缩略块 */}
|
||||
{layout.allNodes.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
"absolute rounded-[1px]",
|
||||
n.id === COORDINATOR_ID ? "bg-primary/70" : "bg-foreground/35",
|
||||
)}
|
||||
style={{
|
||||
left: n.x * mmScale,
|
||||
top: n.y * mmScale,
|
||||
width: NODE_W * mmScale,
|
||||
height: NODE_H * mmScale,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 节点缩略块(回放模式按状态着色) */}
|
||||
{layout.allNodes.map((n) => {
|
||||
const state = n.id === COORDINATOR_ID ? null : seatStates?.[n.id]?.state ?? null;
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
"absolute rounded-[1px]",
|
||||
n.id === COORDINATOR_ID
|
||||
? "bg-primary/70"
|
||||
: state === "done"
|
||||
? "bg-emerald-500/70"
|
||||
: state === "running"
|
||||
? "bg-amber-500/80"
|
||||
: state === "failed"
|
||||
? "bg-red-500/80"
|
||||
: "bg-foreground/35",
|
||||
)}
|
||||
style={{
|
||||
left: n.x * mmScale,
|
||||
top: n.y * mmScale,
|
||||
width: NODE_W * mmScale,
|
||||
height: NODE_H * mmScale,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 当前视口框 */}
|
||||
<div
|
||||
className="absolute border-2 border-primary bg-primary/10"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
|
||||
|
||||
@@ -113,7 +114,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
||||
? (data as { detail: unknown }).detail
|
||||
: `请求失败 (${response.status})`;
|
||||
throw new Error(
|
||||
typeof detail === "string" ? detail : JSON.stringify(detail),
|
||||
userFacingKnowledgeMessage(typeof detail === "string" ? detail : JSON.stringify(detail)),
|
||||
);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
@@ -408,6 +409,17 @@ export async function uploadAssistantWeKnoraPackage(input: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadAssistantDocument(input: { file: File; assistantBaseId?: string | null }) {
|
||||
const body = new FormData();
|
||||
body.set("file", input.file);
|
||||
if (input.assistantBaseId) body.set("assistant_base_id", input.assistantBaseId);
|
||||
return normalizeImportJob(await readJson<AssistantImportJob>(await apiFetch(`${BASE()}/imports/files`, { method: "POST", body })));
|
||||
}
|
||||
|
||||
export async function retryAssistantDocument(jobId: string) {
|
||||
return readJson<AssistantImportJob>(await apiFetch(`${BASE()}/import-jobs/${encodeURIComponent(jobId)}/retry-file`, { method: "POST" }));
|
||||
}
|
||||
|
||||
export async function deleteAssistantKnowledgeSource(sourceId: string) {
|
||||
const response = await apiFetch(
|
||||
`${BASE()}/sources/${encodeURIComponent(sourceId)}`,
|
||||
@@ -490,6 +502,8 @@ export function useAssistantKnowledgeActions() {
|
||||
mutationFn: uploadAssistantWeKnoraPackage,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
uploadDocument: useMutation({ mutationFn: uploadAssistantDocument, onSuccess: invalidate }),
|
||||
retryDocument: useMutation({ mutationFn: retryAssistantDocument, onSuccess: invalidate }),
|
||||
reimportSource: useMutation({
|
||||
mutationFn: reimportAssistantKnowledgeSource,
|
||||
onSuccess: invalidate,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
@@ -85,7 +86,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
: `${context}失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
throw new Error(userFacingKnowledgeMessage(detail));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -249,6 +250,6 @@ export async function deleteEnterpriseResearchTask(taskId: string): Promise<void
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
: `删除研究任务失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
throw new Error(userFacingKnowledgeMessage(detail));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getCsrfToken } from "@/core/api/csrf";
|
||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
||||
@@ -366,10 +367,6 @@ export interface LlmWikiWikiPageUpdate {
|
||||
wiki_path?: string;
|
||||
}
|
||||
|
||||
function userFacingKnowledgeMessage(value: string): string {
|
||||
return value.replace(/weknora/gi, "知识服务");
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
@@ -102,7 +103,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? JSON.stringify((data as { detail: unknown }).detail)
|
||||
: `请求失败 (${response.status})`;
|
||||
throw new Error(detail.replace(/^"|"$/g, ""));
|
||||
throw new Error(userFacingKnowledgeMessage(detail.replace(/^"|"$/g, "")));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
+17
-10
@@ -17,6 +17,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOrdinaryKnowledgeLanding } from "@/components/workspace/knowledge-product-nav";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -139,6 +140,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const details = useLlmWikiKnowledgeBase(
|
||||
knowledgeBaseId || null,
|
||||
!!knowledgeBaseId,
|
||||
@@ -165,6 +167,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||
const documentId = searchParams.get("documentId") || "";
|
||||
const wikiSlug = searchParams.get("wikiSlug") || "";
|
||||
const defaultEntry = searchParams.get("view") !== "weknora" && !requestedTab && !wikiSlug && !sourceChunkId && !documentId;
|
||||
const showNativeIndex = defaultEntry && (landing.data ?? "wiki") === "wiki" && !!detailsWikiEnabled;
|
||||
const nativeWikiPageUrl =
|
||||
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
||||
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
||||
@@ -175,7 +179,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
setFrameSrc("");
|
||||
setFrameError(null);
|
||||
if (
|
||||
nativeWikiPageUrl ||
|
||||
nativeWikiPageUrl || landing.isLoading || showNativeIndex ||
|
||||
!knowledgeBaseId ||
|
||||
runtime.data?.provider !== "weknora" ||
|
||||
!detailsReady
|
||||
@@ -225,6 +229,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
documentId,
|
||||
knowledgeBaseId,
|
||||
nativeWikiPageUrl,
|
||||
landing.isLoading,
|
||||
showNativeIndex,
|
||||
preferredTab,
|
||||
runtime.data?.provider,
|
||||
sourceChunkId,
|
||||
@@ -253,7 +259,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
return <Navigate to={nativeWikiPageUrl} replace />;
|
||||
}
|
||||
|
||||
if (runtime.isLoading || details.isLoading) {
|
||||
if (runtime.isLoading || details.isLoading || landing.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
@@ -262,6 +268,10 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (showNativeIndex) {
|
||||
return <Navigate to={`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`} replace />;
|
||||
}
|
||||
|
||||
if (runtime.data?.provider !== "weknora")
|
||||
return <Navigate to={LIST_PATH} replace />;
|
||||
|
||||
@@ -313,7 +323,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
{canManageWiki && (
|
||||
{detailsWikiEnabled && (
|
||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{canEditCurrentWikiArticle && (
|
||||
@@ -334,6 +344,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`)}>查看 Wiki 索引</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -348,18 +359,14 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
数据包任务
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
{canManageWiki && <Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`,
|
||||
)
|
||||
}
|
||||
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
|
||||
>
|
||||
<FileText className="mr-2 size-4" />
|
||||
Wiki管理
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Archive,
|
||||
@@ -180,7 +181,7 @@ export default function WeKnoraPackageTransfersPage() {
|
||||
</div>
|
||||
{job.error && (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
{job.error}
|
||||
{userFacingKnowledgeMessage(job.error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+11
-10
@@ -153,11 +153,11 @@ export function WeKnoraWikiPageView({
|
||||
const pages = publicEmbed ? publicPages : privatePages;
|
||||
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const explicitIndexPage = useMemo(
|
||||
() => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined),
|
||||
() => remotePagesList.find(isWikiIndexPage),
|
||||
[publicEmbed, remotePagesList],
|
||||
);
|
||||
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
|
||||
() => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage
|
||||
() => remotePagesList.length > 0 && !explicitIndexPage
|
||||
? {
|
||||
id: "__generated_public_index__",
|
||||
slug: GENERATED_PUBLIC_INDEX_SLUG,
|
||||
@@ -181,14 +181,14 @@ export function WeKnoraWikiPageView({
|
||||
[generatedIndexPage, remotePagesList],
|
||||
);
|
||||
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
||||
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
||||
const wikiSlug = requestedWikiSlug || landingPage?.slug || "";
|
||||
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
|
||||
const generatedIndexRequested =
|
||||
publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
||||
wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
||||
const privatePage = useLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
wikiSlug || null,
|
||||
!!noteId && !!wikiSlug && !publicEmbed,
|
||||
!!noteId && !!wikiSlug && !publicEmbed && !generatedIndexActive && !generatedIndexRequested,
|
||||
);
|
||||
const publicPage = usePublicLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
@@ -377,15 +377,15 @@ export function WeKnoraWikiPageView({
|
||||
};
|
||||
|
||||
if (!wikiSlug) {
|
||||
if (publicEmbed && pages.isLoading) {
|
||||
if (pages.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
正在加载公开 Wiki
|
||||
正在加载 Wiki 索引
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (publicEmbed && pages.error) {
|
||||
if (pages.error) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
@@ -405,9 +405,9 @@ export function WeKnoraWikiPageView({
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
||||
该知识库暂无可展示的 Wiki 页面
|
||||
{!publicEmbed && (
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
||||
<><Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button><Button onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>进入知识库管理,导入资料</Button></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -492,6 +492,7 @@ export function WeKnoraWikiPageView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>知识库管理</Button>
|
||||
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
|
||||
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
||||
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
@@ -110,7 +111,7 @@ export function WikiPackageTransfers({
|
||||
{job.file_size_bytes ? (
|
||||
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
||||
) : null}
|
||||
{job.error && <span className="text-destructive">{job.error}</span>}
|
||||
{job.error && <span className="text-destructive">{userFacingKnowledgeMessage(job.error)}</span>}
|
||||
{assistant &&
|
||||
job.status === "completed" &&
|
||||
job.file_exists &&
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/* ============================================================
|
||||
智能体星轨页 — 深色主题(星海蓝)
|
||||
只放颜色,结构与动画在 agents-orbit.css
|
||||
============================================================ */
|
||||
|
||||
.agents-orbit-page.ao-dark {
|
||||
color: #d9e8ff;
|
||||
background:
|
||||
radial-gradient(1100px 640px at 22% 40%, rgba(56, 130, 246, 0.18), transparent 62%),
|
||||
radial-gradient(880px 560px at 80% 26%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
linear-gradient(158deg, #050b1e 0%, #081429 46%, #03060f 100%);
|
||||
}
|
||||
|
||||
/* 星点 */
|
||||
.agents-orbit-page.ao-dark::before {
|
||||
background-image:
|
||||
radial-gradient(1.6px 1.6px at 11% 16%, rgba(255, 255, 255, 0.55), transparent 55%),
|
||||
radial-gradient(1.2px 1.2px at 27% 74%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||
radial-gradient(1.4px 1.4px at 42% 26%, rgba(255, 255, 255, 0.4), transparent 55%),
|
||||
radial-gradient(1px 1px at 58% 82%, rgba(255, 255, 255, 0.3), transparent 55%),
|
||||
radial-gradient(1.6px 1.6px at 66% 12%, rgba(255, 255, 255, 0.5), transparent 55%),
|
||||
radial-gradient(1.2px 1.2px at 79% 58%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||
radial-gradient(1px 1px at 88% 34%, rgba(255, 255, 255, 0.3), transparent 55%),
|
||||
radial-gradient(1.4px 1.4px at 18% 88%, rgba(255, 255, 255, 0.35), transparent 55%),
|
||||
radial-gradient(1px 1px at 35% 48%, rgba(255, 255, 255, 0.25), transparent 55%),
|
||||
radial-gradient(1.2px 1.2px at 92% 78%, rgba(255, 255, 255, 0.3), transparent 55%);
|
||||
}
|
||||
|
||||
/* 顶部标题 */
|
||||
.agents-orbit-page.ao-dark .ao-header-title {
|
||||
color: #cfe4ff;
|
||||
text-shadow: 0 0 14px rgba(90, 160, 255, 0.5);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-header-link {
|
||||
border-color: rgba(110, 170, 255, 0.35);
|
||||
background: rgba(12, 28, 58, 0.7);
|
||||
color: #9fc4f5;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-header-link:hover {
|
||||
color: #ffffff;
|
||||
border-color: rgba(140, 205, 255, 0.8);
|
||||
box-shadow: 0 0 14px rgba(90, 170, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 中心圆盘 */
|
||||
.agents-orbit-page.ao-dark .ao-core {
|
||||
background: radial-gradient(
|
||||
circle at 34% 30%,
|
||||
rgba(96, 165, 250, 0.95),
|
||||
rgba(37, 99, 235, 0.55) 45%,
|
||||
rgba(8, 20, 45, 0.92) 100%
|
||||
);
|
||||
border-color: rgba(125, 190, 255, 0.7);
|
||||
box-shadow:
|
||||
0 0 46px rgba(64, 140, 255, 0.5),
|
||||
inset 0 0 34px rgba(120, 190, 255, 0.35);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-core-img {
|
||||
color: #eaf4ff;
|
||||
filter: drop-shadow(0 0 14px rgba(120, 190, 255, 0.85));
|
||||
}
|
||||
|
||||
/* 圆环族 */
|
||||
.agents-orbit-page.ao-dark .ao-ring--dash {
|
||||
border-color: rgba(110, 170, 255, 0.55);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-ring--arc {
|
||||
border-top-color: rgba(88, 200, 255, 0.9);
|
||||
border-right-color: rgba(88, 200, 255, 0.32);
|
||||
filter: drop-shadow(0 0 6px rgba(88, 200, 255, 0.55));
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-ring--ticks {
|
||||
border-color: rgba(110, 170, 255, 0.28);
|
||||
background-image: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
rgba(120, 180, 255, 0.5) 0deg 0.8deg,
|
||||
transparent 0.8deg 6deg
|
||||
);
|
||||
}
|
||||
|
||||
/* 卫星 */
|
||||
.agents-orbit-page.ao-dark .ao-sat {
|
||||
background: radial-gradient(circle at 35% 30%, #ffffff, #7fd0ff 55%, #1f7dff);
|
||||
box-shadow:
|
||||
0 0 12px 3px rgba(110, 200, 255, 0.85),
|
||||
-14px 0 22px rgba(110, 200, 255, 0.35);
|
||||
}
|
||||
|
||||
/* 类型标签药丸 */
|
||||
.agents-orbit-page.ao-dark .ao-type-chip {
|
||||
color: #b9d7ff;
|
||||
background: rgba(12, 28, 58, 0.82);
|
||||
border-color: rgba(110, 170, 255, 0.35);
|
||||
box-shadow: 0 4px 16px rgba(4, 12, 32, 0.55);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-type-anchor:hover .ao-type-chip {
|
||||
color: #ffffff;
|
||||
border-color: rgba(140, 205, 255, 0.8);
|
||||
box-shadow: 0 0 18px rgba(90, 170, 255, 0.45);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-type-anchor.is-active .ao-type-chip {
|
||||
color: #06122b;
|
||||
background: linear-gradient(135deg, #66c4ff, #2f7dff);
|
||||
border-color: rgba(160, 220, 255, 0.9);
|
||||
box-shadow: 0 0 26px rgba(80, 170, 255, 0.65);
|
||||
}
|
||||
|
||||
/* 右侧面板 */
|
||||
.agents-orbit-page.ao-dark .ao-panel {
|
||||
background: rgba(10, 22, 48, 0.72);
|
||||
border-color: rgba(110, 170, 255, 0.22);
|
||||
box-shadow: 0 10px 40px rgba(2, 8, 24, 0.5);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-panel-head {
|
||||
border-bottom-color: rgba(110, 170, 255, 0.16);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-panel-title {
|
||||
color: #e8f2ff;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-panel-sub {
|
||||
color: #8fb7e8;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agents::-webkit-scrollbar-thumb {
|
||||
background: rgba(110, 170, 255, 0.3);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agent {
|
||||
border-color: rgba(110, 170, 255, 0.18);
|
||||
background: rgba(20, 42, 86, 0.5);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agent:hover {
|
||||
border-color: rgba(130, 200, 255, 0.6);
|
||||
background: rgba(30, 58, 112, 0.65);
|
||||
box-shadow: 0 8px 24px rgba(20, 80, 200, 0.35);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agent-icon {
|
||||
background: linear-gradient(135deg, #2f7dff, #7a5cff);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.25),
|
||||
0 4px 14px rgba(47, 125, 255, 0.4);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-agent-name {
|
||||
color: #d7e8ff;
|
||||
}
|
||||
|
||||
/* 加载 / 空态 */
|
||||
.agents-orbit-page.ao-dark .ao-state {
|
||||
color: #8fb7e8;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-dark .ao-spinner {
|
||||
border-color: rgba(110, 170, 255, 0.25);
|
||||
border-top-color: #6fc2ff;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/* ============================================================
|
||||
智能体星轨页 — 浅色主题(云际蓝)
|
||||
只放颜色,结构与动画在 agents-orbit.css
|
||||
============================================================ */
|
||||
|
||||
.agents-orbit-page.ao-light {
|
||||
color: #1f3a63;
|
||||
background:
|
||||
radial-gradient(1100px 640px at 22% 40%, rgba(47, 125, 255, 0.1), transparent 62%),
|
||||
radial-gradient(880px 560px at 80% 26%, rgba(122, 92, 246, 0.08), transparent 60%),
|
||||
linear-gradient(158deg, #f4f8ff 0%, #e7f0ff 46%, #eef4fd 100%);
|
||||
}
|
||||
|
||||
/* 浅色下不渲染星点 */
|
||||
.agents-orbit-page.ao-light::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 顶部标题 */
|
||||
.agents-orbit-page.ao-light .ao-header-title {
|
||||
color: #164a96;
|
||||
text-shadow: 0 0 14px rgba(120, 170, 255, 0.35);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-header-link {
|
||||
border-color: rgba(47, 125, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: #2a66c2;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-header-link:hover {
|
||||
color: #0f3f96;
|
||||
border-color: rgba(47, 125, 255, 0.85);
|
||||
box-shadow: 0 4px 14px rgba(47, 125, 255, 0.22);
|
||||
}
|
||||
|
||||
/* 中心圆盘 */
|
||||
.agents-orbit-page.ao-light .ao-core {
|
||||
background: radial-gradient(
|
||||
circle at 34% 30%,
|
||||
#ffffff,
|
||||
#dbe9ff 45%,
|
||||
#b9d4ff 100%
|
||||
);
|
||||
border-color: rgba(90, 150, 240, 0.85);
|
||||
box-shadow:
|
||||
0 10px 34px rgba(47, 125, 255, 0.28),
|
||||
inset 0 0 30px rgba(160, 200, 255, 0.55);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-core-img {
|
||||
color: #1f5fd6;
|
||||
filter: drop-shadow(0 4px 10px rgba(47, 125, 255, 0.35));
|
||||
}
|
||||
|
||||
/* 圆环族 */
|
||||
.agents-orbit-page.ao-light .ao-ring--dash {
|
||||
border-color: rgba(47, 125, 255, 0.45);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-ring--arc {
|
||||
border-top-color: rgba(37, 110, 245, 0.85);
|
||||
border-right-color: rgba(37, 110, 245, 0.28);
|
||||
filter: drop-shadow(0 2px 5px rgba(47, 125, 255, 0.3));
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-ring--ticks {
|
||||
border-color: rgba(47, 125, 255, 0.3);
|
||||
background-image: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
rgba(47, 105, 220, 0.45) 0deg 0.8deg,
|
||||
transparent 0.8deg 6deg
|
||||
);
|
||||
}
|
||||
|
||||
/* 卫星 */
|
||||
.agents-orbit-page.ao-light .ao-sat {
|
||||
background: radial-gradient(circle at 35% 30%, #ffffff, #7fb4ff 55%, #2f7dff);
|
||||
box-shadow:
|
||||
0 0 10px 2px rgba(47, 125, 255, 0.55),
|
||||
-14px 0 20px rgba(47, 125, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 类型标签药丸 */
|
||||
.agents-orbit-page.ao-light .ao-type-chip {
|
||||
color: #1e4fa8;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(47, 125, 255, 0.4);
|
||||
box-shadow: 0 4px 14px rgba(30, 80, 180, 0.14);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-type-anchor:hover .ao-type-chip {
|
||||
color: #0f3f96;
|
||||
border-color: rgba(47, 125, 255, 0.85);
|
||||
box-shadow: 0 6px 18px rgba(47, 125, 255, 0.28);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-type-anchor.is-active .ao-type-chip {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #4d9aff, #2f7dff);
|
||||
border-color: rgba(47, 125, 255, 0.95);
|
||||
box-shadow: 0 8px 24px rgba(47, 125, 255, 0.45);
|
||||
}
|
||||
|
||||
/* 右侧面板 */
|
||||
.agents-orbit-page.ao-light .ao-panel {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(47, 125, 255, 0.25);
|
||||
box-shadow: 0 10px 32px rgba(30, 70, 160, 0.12);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-panel-head {
|
||||
border-bottom-color: rgba(47, 125, 255, 0.18);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-panel-title {
|
||||
color: #0f3a78;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-panel-sub {
|
||||
color: #5b7ea8;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agents::-webkit-scrollbar-thumb {
|
||||
background: rgba(47, 125, 255, 0.3);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agent {
|
||||
border-color: rgba(47, 125, 255, 0.22);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agent:hover {
|
||||
border-color: rgba(47, 125, 255, 0.6);
|
||||
background: #f0f7ff;
|
||||
box-shadow: 0 8px 22px rgba(47, 125, 255, 0.22);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agent-icon {
|
||||
background: linear-gradient(135deg, #2f7dff, #7a5cff);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.35),
|
||||
0 4px 12px rgba(47, 125, 255, 0.35);
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-agent-name {
|
||||
color: #1f3a63;
|
||||
}
|
||||
|
||||
/* 加载 / 空态 */
|
||||
.agents-orbit-page.ao-light .ao-state {
|
||||
color: #5b7ea8;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-light .ao-spinner {
|
||||
border-color: rgba(47, 125, 255, 0.25);
|
||||
border-top-color: #2f7dff;
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
/* ============================================================
|
||||
智能体星轨页 — 基础结构 + 动画(不含任何颜色)
|
||||
颜色主题在 agents-orbit-dark.css / agents-orbit-light.css 两个文件里
|
||||
命名统一 ao- 前缀
|
||||
============================================================ */
|
||||
|
||||
/* ---------- 页面骨架:左舞台 / 右面板 1:1 ---------- */
|
||||
|
||||
.agents-orbit-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 星点背景层(内容在主题文件里着色;浅色主题直接隐藏) */
|
||||
.agents-orbit-page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
animation: ao-star-blink 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ao-star-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部小标题 + 返回经典视图入口 */
|
||||
.ao-header {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 22px;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ao-header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.ao-header-link {
|
||||
border: 1px solid;
|
||||
background: none;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: color 0.25s, border-color 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
|
||||
/* ---------- 左侧星轨舞台 ---------- */
|
||||
|
||||
.ao-stage {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ao-root {
|
||||
position: relative;
|
||||
width: min(56vh, 520px);
|
||||
max-width: calc(100% - 48px);
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
/* 中心圆盘(核心) */
|
||||
.ao-core {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 32%;
|
||||
aspect-ratio: 1 / 1;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* 中心图片:上下摇晃(频率适中,约 3.2s 一个来回) */
|
||||
.ao-core-img {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: ao-float 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ao-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(9px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 圆环族 ----------
|
||||
由内到外:虚线环(54%) 顺时针 / 弧光环(70%) 逆时针 / 刻度环(97%) 缓慢顺时针 */
|
||||
|
||||
.ao-ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ao-ring--dash {
|
||||
width: 54%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 2px dashed;
|
||||
animation: ao-spin-cw 18s linear infinite;
|
||||
}
|
||||
|
||||
.ao-ring--arc {
|
||||
width: 70%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 3px solid transparent;
|
||||
animation: ao-spin-ccw 12s linear infinite;
|
||||
}
|
||||
|
||||
/* 最外层刻度圆圈:细边框 + 环形遮罩刻度(密码转盘的刻度感),
|
||||
缓慢自转,转盘属性刻度环 */
|
||||
.ao-ring--ticks {
|
||||
width: 97%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 1px solid;
|
||||
-webkit-mask: radial-gradient(
|
||||
farthest-side,
|
||||
transparent calc(100% - 5px),
|
||||
#000 calc(100% - 4px)
|
||||
);
|
||||
mask: radial-gradient(
|
||||
farthest-side,
|
||||
transparent calc(100% - 5px),
|
||||
#000 calc(100% - 4px)
|
||||
);
|
||||
animation: ao-spin-cw 90s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ao-spin-cw {
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ao-spin-ccw {
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 卫星:一个自转的轨道容器,卫星固定在轨道顶端;
|
||||
轨道直径与最外层刻度圆圈(97%)一致,卫星贴着最外圈环绕 */
|
||||
.ao-sat-orbit {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 97%;
|
||||
aspect-ratio: 1 / 1;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
animation: ao-orbit-cw 9s linear infinite;
|
||||
}
|
||||
|
||||
.ao-sat {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: -7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes ao-orbit-cw {
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 类型标签(密码转盘) ---------- */
|
||||
|
||||
/* 转盘裁剪容器:垂直中线以左(左半圆)是不可视区域,
|
||||
绕背补位的标签滑出边界即被裁掉、从另一端滑入才重新出现。
|
||||
上/右/下方向用负 inset 放宽裁剪框,不切到溢出 root 的标签。 */
|
||||
.ao-types {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
clip-path: inset(-80px -80px -80px 50%);
|
||||
}
|
||||
|
||||
/* 旋转臂:铺满整个 root、以圆心为轴旋转;标签挂在 93%/50% 处(刻度环内侧)。
|
||||
换角度时 CSS 过渡让标签沿圆弧运动,文字由锚点反向旋转保持水平。
|
||||
超界补位的标签角度会 ±360 走“背面长路”,全程经过被裁剪的左半圆。 */
|
||||
.ao-type-arm {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
transform-origin: 50% 50%;
|
||||
transition: transform 0.85s cubic-bezier(0.68, -0.12, 0.28, 1.08);
|
||||
}
|
||||
|
||||
.ao-type-anchor {
|
||||
position: absolute;
|
||||
left: 93%;
|
||||
top: 50%;
|
||||
pointer-events: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
transition: transform 0.85s cubic-bezier(0.68, -0.12, 0.28, 1.08);
|
||||
}
|
||||
|
||||
/* 标签药丸:内层元素承担上下摇晃动画,不与定位 transform 冲突 */
|
||||
.ao-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
animation: ao-bob 3.6s ease-in-out infinite;
|
||||
transition: color 0.3s, border-color 0.3s, box-shadow 0.3s, background 0.3s;
|
||||
}
|
||||
|
||||
.ao-type-chip::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
/* 激活位(正右侧中间)微微放大强调 */
|
||||
.ao-type-anchor.is-active .ao-type-chip {
|
||||
padding: 8px 18px;
|
||||
}
|
||||
|
||||
/* 类型较多时收缩标签尺寸,保持间距可辨 */
|
||||
.agents-orbit-page.ao-crowded .ao-type-chip {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.agents-orbit-page.ao-crowded .ao-type-anchor.is-active .ao-type-chip {
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
@keyframes ao-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 右侧智能体面板(与舞台 1:1) ---------- */
|
||||
|
||||
.ao-panel {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
margin: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ao-panel-head {
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
|
||||
.ao-panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.ao-panel-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ao-agents {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
animation: ao-fade-in 0.5s ease both;
|
||||
}
|
||||
|
||||
.ao-agents::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.ao-agents::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@keyframes ao-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ao-agent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 8px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
transition: transform 0.25s, box-shadow 0.25s, border-color 0.25s, background 0.25s;
|
||||
}
|
||||
|
||||
.ao-agent:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* 圆角正方形图标(取智能体名称首字) */
|
||||
.ao-agent-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ao-agent-name {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- 加载 / 空态 ---------- */
|
||||
|
||||
.ao-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ao-spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid;
|
||||
animation: ao-spin-cw-center 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ao-spin-cw-center {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 动效敏感用户降级 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agents-orbit-page::before,
|
||||
.ao-core-img,
|
||||
.ao-ring--dash,
|
||||
.ao-ring--arc,
|
||||
.ao-ring--ticks,
|
||||
.ao-sat-orbit,
|
||||
.ao-type-chip,
|
||||
.ao-agents {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.ao-type-arm,
|
||||
.ao-type-anchor {
|
||||
transition-duration: 0.01s;
|
||||
}
|
||||
}
|
||||
@@ -1762,9 +1762,12 @@ gone.
|
||||
|
||||
Assistant knowledge has one global full-library base (`知识梳理总库`) and any
|
||||
number of admin-created custom bases. Ordinary users have read/search access and
|
||||
administrators own all mutations. Assistant bases only accept WeKnora export
|
||||
packages or imports triggered from a WeKnora mapping. Do not add direct skill
|
||||
imports or arbitrary local-file imports to assistant knowledge. Importing into a
|
||||
administrators own all mutations. Assistant bases accept local documents independently
|
||||
of WeKnora via `/api/assistant-knowledge/imports/files`, as well as WeKnora export
|
||||
packages or imports triggered from a WeKnora mapping. Local documents use the
|
||||
Gateway `assistant_file_ingest` pipeline: persisted original files, chunked LLM Wiki
|
||||
extraction, cached body embeddings, and import-job status/retry. Do not add direct skill
|
||||
imports. Importing into a
|
||||
custom assistant base must also mirror the package into the global base for
|
||||
cross-source entity alignment. Admins may rename any assistant base and delete
|
||||
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
|
||||
@@ -1773,6 +1776,8 @@ deletable because it owns full-library aggregation.
|
||||
|
||||
### Wiki package and local encoder invariants (2026-09)
|
||||
|
||||
Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_landing` (`wiki` default / `weknora`), with admin-only PUT `/api/system-settings/ordinary-knowledge-landing`. Explicit document/graph/article links bypass this default. Wiki reading and the embedded WeKnora view provide reciprocal navigation; `?view=weknora` explicitly selects the iframe and must not loop back to the default Wiki route.
|
||||
|
||||
- `knowledge_transfer.sync_to_global` is called after successful explicit local
|
||||
Wiki vectorization; it skips conversation/system mappings. Do not enable a new
|
||||
watcher or automatic remote-write scheduler as a side effect.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# DeerFlow Backend
|
||||
|
||||
普通知识库默认详情页面:管理员可在知识库顶部选择“本系统 Wiki 索引”或“嵌入 WeKnora”,对应 `ordinary_knowledge_landing`(默认 `wiki`)。Wiki 库首次进入默认展示本系统索引,右上角“打开 WeKnora”切换到内嵌管理页;内嵌页可返回索引。非 Wiki 库仍进入 WeKnora 文档页。设置通过 `/api/system-settings/ordinary-knowledge-landing` 保存,仅管理员可修改;指定文章、文档、图谱的直达链接优先于默认设置。
|
||||
|
||||
助手知识库可独立于 WeKnora 使用:管理员进入助手知识库,选择或创建库后点击“上传文件生成 Wiki”。支持 Markdown/TXT/CSV、PDF、Word、Excel、PowerPoint(单文件 100 MB,解析正文上限 400 万字符)。文件先写入运行目录 `assistant-knowledge/files/{job_id}`,后台分段调用已配置的语言模型生成分类 Wiki、实体关系,再通过 DeerFlow 编码器对正文分段向量化,保存到助手库并汇入知识梳理总库。无需配置 WeKnora URL;配置后仍可直接上传,也保留 WeKnora 数据包导入。文件处理沿用管理员写入权限,所有用户可以浏览和搜索。
|
||||
|
||||
导入记录显示解析、Wiki 生成和向量化阶段;失败或服务中断后可“重试 / 恢复处理”,复用已落盘的 Wiki 和相同模型指纹的向量。离线编码器须预先放置模型并在管理员设置中配置;模型或编码器不可用会明确失败,不把摘要冒充 Wiki 或虚构向量。扫描件是否可解析取决于部署的文档解析/OCR 能力。
|
||||
|
||||
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Local document → Wiki → vectors, independent of the WeKnora service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deerflow.config.runtime_paths import runtime_home
|
||||
from deerflow.knowledge.llm_extractor import extract_document_llm_multi
|
||||
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
|
||||
|
||||
ALLOWED_EXTENSIONS = CONVERTIBLE_EXTENSIONS | {".md", ".markdown", ".txt", ".csv"}
|
||||
MAX_FILE_BYTES = 100 * 1024 * 1024
|
||||
MAX_TEXT_CHARS = 4_000_000
|
||||
_tasks: dict[str, asyncio.Task] = {}
|
||||
_slots = asyncio.Semaphore(2)
|
||||
_queue_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def file_job_dir(job_id: str) -> Path:
|
||||
from uuid import UUID
|
||||
|
||||
return runtime_home() / "assistant-knowledge" / "files" / str(UUID(job_id))
|
||||
|
||||
|
||||
def save_json(path: Path, value: Any) -> None:
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def split_text(text: str, size: int):
|
||||
"""Bound every extraction/embedding input without truncating the document."""
|
||||
for offset in range(0, len(text), size):
|
||||
yield text[offset : offset + size]
|
||||
|
||||
|
||||
def notes_package(notes: list[dict], source: dict) -> dict:
|
||||
from deerflow.persistence.assistant_knowledge.store import _entity_alignment_key
|
||||
|
||||
merged: dict[str, dict] = {}
|
||||
for raw in notes:
|
||||
key = _entity_alignment_key(raw["title"])
|
||||
if key not in merged:
|
||||
merged[key] = dict(raw)
|
||||
continue
|
||||
note = merged[key]
|
||||
note["content_md"] = "\n\n".join(dict.fromkeys([note["content_md"], raw["content_md"]]))
|
||||
for field in ("tags", "entities", "relations"):
|
||||
note[field] = [*note.get(field, []), *raw.get(field, [])]
|
||||
note["tags"] = list(dict.fromkeys(note["tags"]))
|
||||
pages, entities, relations = [], [], []
|
||||
for index, note in enumerate(merged.values()):
|
||||
slug = f"files/{source['digest']}/{index}"
|
||||
pages.append(
|
||||
{
|
||||
"slug": slug,
|
||||
"title": note["title"],
|
||||
"content": note["content_md"],
|
||||
"page_type": "article",
|
||||
"tags": note.get("tags", []),
|
||||
"category_path": [note.get("category") or "知识文档"],
|
||||
"source_refs": [{"type": "file", "title": source["filename"]}],
|
||||
}
|
||||
)
|
||||
for entity in note.get("entities", []):
|
||||
entities.append({"id": entity["name"], "name": entity["name"], "entity_type": entity.get("type", "concept"), "aliases": entity.get("aliases", []), "source_wiki_slug": slug})
|
||||
for relation in note.get("relations", []):
|
||||
relations.append({"source": relation["from"], "target": relation["to"], "predicate": relation.get("type", "related_to"), "source_wiki_slug": slug})
|
||||
# Persist real sibling links, understood by the shared Wiki reader.
|
||||
for index, page in enumerate(pages):
|
||||
others = [p for p in pages[max(0, index - 4) : index + 5] if p["slug"] != page["slug"]]
|
||||
page["out_links"] = [p["slug"] for p in others]
|
||||
page["in_links"] = list(page["out_links"])
|
||||
if others:
|
||||
page["content"] += "\n\n## 相关知识\n" + "\n".join(f"- [[{p['slug']}|{p['title']}]]" for p in others)
|
||||
return {"source": source, "wiki_pages": pages, "entities": entities, "relations": relations, "vectors": []}
|
||||
|
||||
|
||||
async def run_file_job(app, job_id: str) -> None:
|
||||
store = app.state.assistant_knowledge_store
|
||||
root = file_job_dir(job_id)
|
||||
try:
|
||||
async with _slots:
|
||||
manifest = await asyncio.to_thread(read_json, root / "manifest.json")
|
||||
await store.update_import_job(job_id, status="running", phase="parsing")
|
||||
package_file = root / "wiki.json"
|
||||
if package_file.exists():
|
||||
package = await asyncio.to_thread(read_json, package_file)
|
||||
else:
|
||||
text_file = root / "parsed.md"
|
||||
if not text_file.exists():
|
||||
original = root / manifest["stored_name"]
|
||||
if original.suffix in CONVERTIBLE_EXTENSIONS:
|
||||
converted = await asyncio.to_thread(lambda: asyncio.run(convert_file_to_markdown(original)))
|
||||
if converted is None:
|
||||
raise ValueError("无法解析文档,请检查文件是否损坏或是否需要 OCR")
|
||||
text = await asyncio.to_thread(converted.read_text, encoding="utf-8")
|
||||
else:
|
||||
raw = await asyncio.to_thread(original.read_bytes)
|
||||
try:
|
||||
text = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode("gb18030")
|
||||
if not text.strip() or len(text) > MAX_TEXT_CHARS:
|
||||
raise ValueError("文件没有可提取文本,或解析正文超过 400 万字符,请拆分文件")
|
||||
await asyncio.to_thread(text_file.write_text, text, encoding="utf-8")
|
||||
else:
|
||||
text = await asyncio.to_thread(text_file.read_text, encoding="utf-8")
|
||||
notes = []
|
||||
parts = list(split_text(text, 10000))
|
||||
for index, part in enumerate(parts):
|
||||
await store.update_import_job(job_id, phase="generating_wiki", counts={"parts_total": len(parts), "parts_done": index})
|
||||
cache = root / f"notes-{index}.json"
|
||||
if cache.exists():
|
||||
drafts = await asyncio.to_thread(read_json, cache)
|
||||
else:
|
||||
from dataclasses import asdict
|
||||
|
||||
extracted = await asyncio.wait_for(
|
||||
extract_document_llm_multi(
|
||||
part,
|
||||
doc_id=f"{job_id}:{index}",
|
||||
doc_title=manifest["filename"],
|
||||
max_input_chars=12000,
|
||||
max_notes=6,
|
||||
),
|
||||
timeout=300,
|
||||
)
|
||||
if not extracted:
|
||||
raise ValueError("Wiki 生成失败,请在设置中检查可用的语言模型,然后重试")
|
||||
drafts = [asdict(note) for note in extracted]
|
||||
await asyncio.to_thread(save_json, cache, drafts)
|
||||
notes.extend(drafts)
|
||||
package = notes_package(notes, {"type": "file", "filename": manifest["filename"], "digest": manifest["digest"]})
|
||||
await asyncio.to_thread(save_json, package_file, package)
|
||||
embedding = getattr(app.state, "llmwiki_embedding", None)
|
||||
if embedding is None:
|
||||
raise ValueError("Wiki 已生成;请管理员在设置中配置本地编码器或编码服务,然后重试向量化")
|
||||
vectors = []
|
||||
sections = [(p, i, section) for p in package["wiki_pages"] for i, section in enumerate(split_text(p["content"], 1600))]
|
||||
for index, (page, section_index, content) in enumerate(sections):
|
||||
await store.update_import_job(job_id, phase="vectorizing", counts={"wiki_pages": len(package["wiki_pages"]), "vectors_total": len(sections), "vectors": index})
|
||||
digest = hashlib.sha256(content.encode()).hexdigest()
|
||||
cache = root / f"vector-{digest}.json"
|
||||
record = await asyncio.to_thread(read_json, cache) if cache.exists() else None
|
||||
if record is None or record["embedding_fingerprint"] != embedding.fingerprint:
|
||||
values = await embedding.embed_texts([content])
|
||||
record = {"vector": values[0], "embedding_model": embedding.config.model, "embedding_fingerprint": embedding.fingerprint, "content_hash": digest}
|
||||
await asyncio.to_thread(save_json, cache, record)
|
||||
vectors.append({**record, "wiki_slug": page["slug"], "section_index": section_index, "section_content": content})
|
||||
package["vectors"] = vectors
|
||||
await store.update_import_job(job_id, phase="importing")
|
||||
await store.import_package_for_job(job_id, package=package, created_by=manifest["actor"])
|
||||
except asyncio.CancelledError:
|
||||
await store.update_import_job(job_id, status="failed", phase="interrupted", error="服务停止,已保留处理进度,可重试", completed=True)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await store.update_import_job(job_id, status="failed", phase="failed", error=str(exc) or type(exc).__name__, completed=True)
|
||||
|
||||
|
||||
def start_file_job(app, job_id: str) -> bool:
|
||||
if job_id in _tasks:
|
||||
return False
|
||||
task = asyncio.create_task(run_file_job(app, job_id))
|
||||
_tasks[job_id] = task
|
||||
task.add_done_callback(lambda _: _tasks.pop(job_id, None))
|
||||
return True
|
||||
|
||||
|
||||
async def stop_file_jobs() -> None:
|
||||
tasks = list(_tasks.values())
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def retry_file_job(app, job_id: str) -> bool:
|
||||
async with _queue_lock:
|
||||
if job_id in _tasks:
|
||||
return False
|
||||
await app.state.assistant_knowledge_store.update_import_job(job_id, status="queued", phase="queued")
|
||||
return start_file_job(app, job_id)
|
||||
@@ -701,6 +701,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
from app.gateway.assistant_file_ingest import stop_file_jobs
|
||||
|
||||
await stop_file_jobs()
|
||||
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||
|
||||
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
"""内置智能体「用途」默认标签的种子化。
|
||||
"""Seed default purpose tags for built-in agents.
|
||||
|
||||
把**功能型内置 agent**(roundtable-* / ai-writing-* / workflow-planner)按用途默认打上标签,使管理员在
|
||||
「内置智能体」筛选里一眼能按用途归类。没有明确用途的内置 agent(各种测试/用户残留的
|
||||
user_id 为空的 agent)**不打标签**。
|
||||
|
||||
用途映射(可继续补充):
|
||||
- **多智能体规划**:roundtable-intent(任务理解)/ roundtable-recommender(智能体推荐)/
|
||||
roundtable-coordinator(总控协调)/ roundtable-report / roundtable-summary /
|
||||
roundtable-execution-plan / roundtable-risk-review / roundtable-solution-design
|
||||
- **AI写作**:ai-writing-researcher / ai-writing-outliner / ai-writing-writer(作家)/
|
||||
ai-writing-editor
|
||||
- **工作流编排**:workflow-planner(只做候选工作流规划,不参与圆桌会商或正式执行)
|
||||
|
||||
幂等:标签缺失则建,assignment 缺失则补;**只做并集追加**,绝不删用户/管理员后加的
|
||||
其它标签。在启动 lifespan 里 `_sync_legacy_agents` 之后调用(那时 agent 行已入库)。
|
||||
Known built-in agents receive their specific purpose tag. Other built-in
|
||||
agents receive the fallback purpose tag so newly added built-ins are not
|
||||
omitted. Existing user or administrator tags are never removed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,48 +11,56 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 标签按用途分组(tag_type 固定为 "agent",与 target_type 对齐)。id 用稳定 slug,
|
||||
# 便于幂等;name 是展示名;color 走标签库的命名色。
|
||||
_PURPOSE_TAGS: dict[str, dict[str, str]] = {
|
||||
"多智能体规划": {"id": "builtin-purpose-multi-agent-planning", "color": "blue"},
|
||||
"AI写作": {"id": "builtin-purpose-ai-writing", "color": "green"},
|
||||
"模板填充": {"id": "builtin-purpose-template-filling", "color": "orange"},
|
||||
"任务研判": {"id": "builtin-purpose-task-analysis", "color": "purple"},
|
||||
"岗位会商": {"id": "builtin-purpose-position-roundtable", "color": "cyan"},
|
||||
"深度研究": {"id": "builtin-purpose-deep-research", "color": "indigo"},
|
||||
"强制检索": {"id": "builtin-purpose-forced-research", "color": "red"},
|
||||
"工作流编排": {"id": "builtin-purpose-workflow-planning", "color": "indigo"},
|
||||
"其他内置功能": {"id": "builtin-purpose-other", "color": "gray"},
|
||||
}
|
||||
|
||||
# 内置 agent id → 用途名(必须是 _PURPOSE_TAGS 的 key)。未列出的内置 agent 不打标签。
|
||||
# Every value must be a key in _PURPOSE_TAGS.
|
||||
_AGENT_PURPOSE: dict[str, str] = {
|
||||
# 多智能体规划(圆桌全链路)
|
||||
"agentfx-analyst": "任务研判",
|
||||
"position-roundtable-intent": "岗位会商",
|
||||
"roundtable-intent": "多智能体规划",
|
||||
"roundtable-recommender": "多智能体规划",
|
||||
"roundtable-coordinator": "多智能体规划",
|
||||
"roundtable-report": "多智能体规划",
|
||||
"roundtable-summary": "多智能体规划",
|
||||
"position-action-planner": "多智能体规划",
|
||||
"roundtable-dashboard": "多智能体规划",
|
||||
"roundtable-structure": "多智能体规划",
|
||||
"roundtable-execution-plan": "多智能体规划",
|
||||
"roundtable-risk-review": "多智能体规划",
|
||||
"roundtable-solution-design": "多智能体规划",
|
||||
# 8BF 独立会商第一步的内置意图智能体(roundtable-intent 的同源复制)
|
||||
"eightbf-roundtable-intent": "多智能体规划",
|
||||
# AI 写作(杂志社协作写作四阶段)
|
||||
"ai-writing-researcher": "AI写作",
|
||||
"ai-writing-outliner": "AI写作",
|
||||
"ai-writing-writer": "AI写作",
|
||||
"ai-writing-editor": "AI写作",
|
||||
# Workflow Studio(候选流程总控,和圆桌总控隔离)
|
||||
"template-json-builder": "模板填充",
|
||||
"deep-research-collector": "深度研究",
|
||||
"forced-research-responder": "强制检索",
|
||||
"workflow-planner": "工作流编排",
|
||||
}
|
||||
|
||||
_TARGET_TYPE = "agent"
|
||||
_FALLBACK_PURPOSE = "其他内置功能"
|
||||
|
||||
|
||||
async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int:
|
||||
"""确保内置功能型 agent 带上默认「用途」标签。返回新增 assignment 数。
|
||||
"""Ensure built-in agents have a default purpose tag.
|
||||
|
||||
错误只记录不抛——种子化失败绝不能阻塞服务启动。
|
||||
Returns the number of newly created tag assignments. Failures are logged
|
||||
instead of blocking application startup.
|
||||
"""
|
||||
try:
|
||||
# 1) 确保用途标签存在,建立 name -> id 映射。
|
||||
existing = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)}
|
||||
existing = {tag.get("name"): tag for tag in await tag_store.list_tags(_TARGET_TYPE)}
|
||||
name_to_id: dict[str, str] = {}
|
||||
for name, meta in _PURPOSE_TAGS.items():
|
||||
found = existing.get(name)
|
||||
@@ -76,25 +73,26 @@ async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int:
|
||||
)
|
||||
name_to_id[name] = created["id"]
|
||||
except ValueError:
|
||||
# 并发/已存在(按 name 唯一)——重查一次取其 id。
|
||||
again = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)}
|
||||
again = {tag.get("name"): tag for tag in await tag_store.list_tags(_TARGET_TYPE)}
|
||||
if name in again:
|
||||
name_to_id[name] = again[name]["id"]
|
||||
|
||||
# 2) 给映射里**实际存在**的内置 agent 追加用途标签(并集,不覆盖已有标签)。
|
||||
builtin_ids = {
|
||||
agent["id"]
|
||||
for agent in await agent_store.list_all()
|
||||
if agent.get("user_id") is None
|
||||
}
|
||||
added = 0
|
||||
for agent_id, purpose in _AGENT_PURPOSE.items():
|
||||
for agent_id in builtin_ids:
|
||||
purpose = _AGENT_PURPOSE.get(agent_id, _FALLBACK_PURPOSE)
|
||||
tag_id = name_to_id.get(purpose)
|
||||
if not tag_id:
|
||||
continue
|
||||
agent = await agent_store.get_any(agent_id)
|
||||
if agent is None:
|
||||
continue # 该部署未装此内置 agent,跳过
|
||||
current = await tag_store.get_target_tags(_TARGET_TYPE, agent_id)
|
||||
cur_ids = [t["id"] for t in current]
|
||||
if tag_id in cur_ids:
|
||||
continue # 已打过该用途标签
|
||||
await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*cur_ids, tag_id])
|
||||
current_ids = [tag["id"] for tag in current]
|
||||
if tag_id in current_ids:
|
||||
continue
|
||||
await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*current_ids, tag_id])
|
||||
added += 1
|
||||
if added:
|
||||
logger.info("Seeded %d built-in agent purpose tag assignment(s)", added)
|
||||
|
||||
@@ -152,6 +152,18 @@ def _list_export_manifests(mapping_id: str, *, limit: int) -> list[dict[str, Any
|
||||
|
||||
def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None:
|
||||
"""Delete package artifacts owned by one assistant base only."""
|
||||
import shutil
|
||||
|
||||
from app.gateway.assistant_file_ingest import file_job_dir
|
||||
|
||||
for job_id in import_job_ids:
|
||||
# UUID validation confines cleanup to exactly one job directory.
|
||||
try:
|
||||
directory = file_job_dir(job_id)
|
||||
except ValueError:
|
||||
continue
|
||||
if directory.is_dir() and not directory.is_symlink():
|
||||
shutil.rmtree(directory)
|
||||
root = _package_root()
|
||||
if not root.is_dir():
|
||||
return
|
||||
@@ -448,10 +460,71 @@ async def import_skills(request: Request, _body: SkillImportRequest) -> dict[str
|
||||
raise HTTPException(status_code=410, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库后再导入助手知识库")
|
||||
|
||||
|
||||
@router.post("/imports/files", status_code=410)
|
||||
async def import_files(request: Request) -> dict[str, Any]:
|
||||
@router.post("/imports/files", status_code=202)
|
||||
async def import_files(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
assistant_base_id: str | None = Form(default=None),
|
||||
) -> dict[str, Any]:
|
||||
import hashlib
|
||||
|
||||
from app.gateway.assistant_file_ingest import ALLOWED_EXTENSIONS, MAX_FILE_BYTES, file_job_dir, save_json, start_file_job
|
||||
|
||||
actor = await _actor(request, admin=True)
|
||||
filename = (file.filename or "document").replace("\\", "/").rsplit("/", 1)[-1][:240]
|
||||
extension = Path(filename).suffix.lower()
|
||||
if extension not in ALLOWED_EXTENSIONS:
|
||||
await file.close()
|
||||
raise HTTPException(status_code=415, detail="支持 Markdown、TXT、CSV、PDF、Word、Excel、PowerPoint 文件")
|
||||
job = await _store(request).create_queued_import_job(
|
||||
source_type="file", source_key=f"file-upload:{uuid4()}", source_name=filename,
|
||||
trigger="local_file_upload", created_by=actor, base_id=assistant_base_id,
|
||||
metadata={"type": "file", "filename": filename}, phase="uploading",
|
||||
)
|
||||
root = file_job_dir(job["id"])
|
||||
await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True)
|
||||
path = root / f"original{extension}"
|
||||
size, digest = 0, hashlib.sha256()
|
||||
try:
|
||||
with path.open("wb") as handle:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
raise HTTPException(status_code=413, detail="单个文件最大 100 MB,请拆分后上传")
|
||||
digest.update(chunk)
|
||||
await asyncio.to_thread(handle.write, chunk)
|
||||
if not size:
|
||||
raise HTTPException(status_code=422, detail="上传文件为空")
|
||||
await asyncio.to_thread(save_json, root / "manifest.json", {
|
||||
"filename": filename, "stored_name": path.name, "digest": digest.hexdigest(), "actor": actor,
|
||||
})
|
||||
job = await _store(request).update_import_job(job["id"], phase="queued", counts={"uploaded_bytes": size})
|
||||
start_file_job(request.app, job["id"])
|
||||
return job
|
||||
except Exception as exc:
|
||||
await _store(request).update_import_job(job["id"], status="failed", phase="upload_failed", error=str(getattr(exc, "detail", exc)), completed=True)
|
||||
await asyncio.to_thread(path.unlink, missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
|
||||
@router.post("/import-jobs/{job_id}/retry-file", status_code=202)
|
||||
async def retry_file_import(request: Request, job_id: str) -> dict[str, Any]:
|
||||
from app.gateway.assistant_file_ingest import file_job_dir, retry_file_job
|
||||
|
||||
await _actor(request, admin=True)
|
||||
raise HTTPException(status_code=410, detail="助手知识库不支持导入任意本地文件,请从 WeKnora 普通知识库导出后导入")
|
||||
store = _store(request)
|
||||
job = await store.get_job(job_id)
|
||||
if not job or job.get("trigger") != "local_file_upload":
|
||||
raise HTTPException(status_code=404, detail="文件处理任务不存在")
|
||||
if job["status"] == "completed":
|
||||
raise HTTPException(status_code=409, detail="文件已处理完成")
|
||||
if not (file_job_dir(job_id) / "manifest.json").exists():
|
||||
raise HTTPException(status_code=409, detail="文件上传未完成,请重新上传")
|
||||
if not await retry_file_job(request.app, job_id):
|
||||
raise HTTPException(status_code=409, detail="文件仍在处理中")
|
||||
return await store.get_job(job_id)
|
||||
|
||||
|
||||
@router.post("/imports/weknora-package", status_code=202)
|
||||
|
||||
@@ -302,7 +302,29 @@ async def send_message(
|
||||
agent_run_id=body.agent_run_id,
|
||||
node_run_id=body.target_node_id,
|
||||
)
|
||||
await RequirementResolver(store).handle_user_message(session_id, user_message=created, idempotency_key=key)
|
||||
decision = await RequirementResolver(
|
||||
store,
|
||||
config=get_app_config().report_collaboration,
|
||||
planner_llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||
).handle_user_message(session_id, user_message=created, idempotency_key=key)
|
||||
# 确认完意图直接规划工作流(设计 v2 衔接):ready 且尚无方案时,
|
||||
# 同一次请求内自动发起规划,用户不用再手动点「生成候选方案」。
|
||||
if decision is not None and decision.action == "ready":
|
||||
record = await store.get_session_record(session_id)
|
||||
plans = await store.list_plans(session_id)
|
||||
if not plans and record and not record.get("selected_plan_id"):
|
||||
command = await store.enqueue_plan_request(
|
||||
session_id,
|
||||
idempotency_key=f"{key}:autoplan",
|
||||
expected_revision=decision.requirement.revision,
|
||||
)
|
||||
if command.get("status") == "pending":
|
||||
await PlanProposalService(
|
||||
store,
|
||||
agent_store=getattr(request.app.state, "agent_store", None),
|
||||
config=get_app_config().report_collaboration,
|
||||
llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=f"{key}:autoplan")
|
||||
return created
|
||||
except Exception as exc:
|
||||
return _map_store_error(exc)
|
||||
@@ -333,6 +355,8 @@ async def request_plans(
|
||||
await PlanProposalService(
|
||||
store,
|
||||
agent_store=getattr(request.app.state, "agent_store", None),
|
||||
config=get_app_config().report_collaboration,
|
||||
llm_factory=getattr(request.app.state, "report_collaboration_planner_llm", None),
|
||||
).fulfill(session_id, user_id=user_id, command_id=command["command_id"], idempotency_key=key)
|
||||
return Response(status_code=204)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -56,6 +56,22 @@ class KnowledgeBaseProductVersionUpdateRequest(BaseModel):
|
||||
version: Literal["v1", "v2"]
|
||||
|
||||
|
||||
class OrdinaryKnowledgeLanding(BaseModel):
|
||||
mode: Literal["wiki", "weknora"] = "wiki"
|
||||
|
||||
|
||||
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
||||
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
|
||||
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
|
||||
|
||||
|
||||
@router.put("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
||||
async def update_ordinary_knowledge_landing(body: OrdinaryKnowledgeLanding, request: Request) -> OrdinaryKnowledgeLanding:
|
||||
await _require_admin(request)
|
||||
saved = save_system_settings(load_system_settings().model_copy(update={"ordinary_knowledge_landing": body.mode}))
|
||||
return OrdinaryKnowledgeLanding(mode=saved.ordinary_knowledge_landing)
|
||||
|
||||
|
||||
class PromptPrefixUpdateRequest(BaseModel):
|
||||
enabled: bool | None = None
|
||||
switch_label: str | None = None
|
||||
|
||||
+86
@@ -91,6 +91,62 @@ class AttemptUsage:
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
# AgentScope 的 Agent.compress_context 会读 model.context_size 并调 count_tokens。
|
||||
_DEFAULT_CONTEXT_SIZE = 128_000
|
||||
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
|
||||
|
||||
|
||||
def estimate_tokens_for_text(text: str) -> int:
|
||||
"""CJK-aware local token estimate: ~1 token per CJK char, ~4 bytes otherwise."""
|
||||
|
||||
if not text:
|
||||
return 0
|
||||
cjk = len(_CJK_RE.findall(text))
|
||||
other = len(text) - cjk
|
||||
return cjk + other // 4 + (1 if other % 4 else 0)
|
||||
|
||||
|
||||
def _collect_countable_text(messages: list[Any]) -> str:
|
||||
parts: list[str] = []
|
||||
for msg in messages or []:
|
||||
blocks: Any = None
|
||||
getter = getattr(msg, "get_content_blocks", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
blocks = getter()
|
||||
except Exception:
|
||||
blocks = None
|
||||
if blocks is not None:
|
||||
for block in blocks:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict):
|
||||
for key in ("text", "thinking", "hint"):
|
||||
value = block.get(key)
|
||||
if isinstance(value, str):
|
||||
parts.append(value)
|
||||
elif isinstance(value, list):
|
||||
parts.extend(str(item) for item in value)
|
||||
else:
|
||||
text = getattr(block, "text", None) or getattr(block, "thinking", None)
|
||||
if text:
|
||||
parts.append(str(text))
|
||||
continue
|
||||
if isinstance(msg, dict):
|
||||
parts.append(str(msg.get("content") or ""))
|
||||
else:
|
||||
raw = getattr(msg, "content", "")
|
||||
if isinstance(raw, list):
|
||||
for block in raw:
|
||||
if isinstance(block, dict):
|
||||
parts.append(str(block.get("text") or ""))
|
||||
else:
|
||||
parts.append(str(getattr(block, "text", block) if not isinstance(block, str) else block))
|
||||
else:
|
||||
parts.append(str(raw or ""))
|
||||
return "\n".join(parts)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return redact_mapping(
|
||||
{
|
||||
@@ -244,6 +300,21 @@ def to_lc_messages(messages: list[Any]) -> list[Any]:
|
||||
return converted
|
||||
|
||||
|
||||
def _resolve_context_size(app_config: AppConfig | None, config_name: str) -> int:
|
||||
"""Best-effort context window from model config extras; sane default otherwise."""
|
||||
|
||||
try:
|
||||
cfg = (app_config or get_app_config()).get_model_config(config_name)
|
||||
extra = cfg.model_dump() if cfg is not None else {}
|
||||
for key in ("context_size", "context_length", "context_window", "max_context_tokens"):
|
||||
value = extra.get(key)
|
||||
if isinstance(value, (int, float)) and int(value) > 0:
|
||||
return int(value)
|
||||
except Exception:
|
||||
pass
|
||||
return _DEFAULT_CONTEXT_SIZE
|
||||
|
||||
|
||||
class DeerFlowChatModelAdapter:
|
||||
"""Frozen DeerFlow-backed chat model. Construct once per node attempt from a snapshot."""
|
||||
|
||||
@@ -266,6 +337,21 @@ class DeerFlowChatModelAdapter:
|
||||
self._llm_factory = llm_factory
|
||||
self._node_run_id = node_run_id
|
||||
self.last_usage = AttemptUsage(model_name=snapshot.config_name, node_run_id=node_run_id)
|
||||
# AgentScope compress_context 契约:读 context_size 判断是否压缩。
|
||||
self.context_size = _resolve_context_size(app_config, snapshot.config_name)
|
||||
|
||||
async def count_tokens(self, messages: list[Any], tools: list[dict[str, Any]] | None = None) -> int:
|
||||
"""AgentScope 契约方法(compress_context 调用)。本地估算,不打 API。"""
|
||||
|
||||
import json as _json
|
||||
|
||||
total = estimate_tokens_for_text(_collect_countable_text(messages))
|
||||
if tools:
|
||||
try:
|
||||
total += estimate_tokens_for_text(_json.dumps(tools, ensure_ascii=False, default=str))
|
||||
except Exception:
|
||||
total += sum(estimate_tokens_for_text(str(item)) for item in tools)
|
||||
return total
|
||||
|
||||
def _config(self) -> AppConfig:
|
||||
return self._app_config or get_app_config()
|
||||
|
||||
@@ -47,7 +47,18 @@ class ReportCollaborationExecutor:
|
||||
existing = self._tasks.get(params.run_id)
|
||||
if existing is not None and not existing.done():
|
||||
return False
|
||||
self._tasks[params.run_id] = asyncio.create_task(self.execute(params), name=f"rc-run-{params.run_id}")
|
||||
task = asyncio.create_task(self.execute(params), name=f"rc-run-{params.run_id}")
|
||||
|
||||
def _log_crash(finished: asyncio.Task[str]) -> None:
|
||||
if not finished.cancelled() and finished.exception() is not None:
|
||||
logger.error(
|
||||
"rc executor task crashed for run %s",
|
||||
params.run_id,
|
||||
exc_info=finished.exception(),
|
||||
)
|
||||
|
||||
task.add_done_callback(_log_crash)
|
||||
self._tasks[params.run_id] = task
|
||||
return True
|
||||
|
||||
async def drain(self) -> None:
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.report_collaboration.execution.board import ArtifactBoard
|
||||
from app.report_collaboration.execution.completion import late_output_may_write_completed, require_can_complete_node
|
||||
from app.report_collaboration.execution.quality_gate import QualityGate, QualityGateContext, requirement_from_session_record
|
||||
from deerflow.persistence.report_collaboration.base import ReportCollaborationStore
|
||||
from deerflow.persistence.report_collaboration.codec import iso_now, make_node_run_id
|
||||
from deerflow.persistence.report_collaboration.codec import _TERMINAL_RUN_STATUSES, iso_now, make_node_run_id
|
||||
|
||||
TaskResultKind = Literal["artifact_draft", "clarification_request", "repair_request", "failure", "advice"]
|
||||
|
||||
@@ -314,6 +314,7 @@ class TaskLedger:
|
||||
command_id: str,
|
||||
instruction: str,
|
||||
node_ids: list[str],
|
||||
cascade_downstream: bool = False,
|
||||
) -> list[str]:
|
||||
"""Supersede affected attempts and persist an immutable command overlay.
|
||||
|
||||
@@ -321,6 +322,10 @@ class TaskLedger:
|
||||
``superseded`` and is therefore stored only as superseded evidence.
|
||||
User-directed rework is not counted against automatic quality-repair
|
||||
limits; it creates the next monotonic attempt number.
|
||||
|
||||
``cascade_downstream=True`` additionally supersedes every transitive
|
||||
dependent of ``node_ids`` and re-plans it, so the wave loop re-runs
|
||||
the whole affected sub-DAG in dependency order (设计 §4.2 指定重跑).
|
||||
"""
|
||||
|
||||
plan, run, latest, artifacts = await self._load(run_id)
|
||||
@@ -407,8 +412,112 @@ class TaskLedger:
|
||||
active_dedupe_key=str(run["session_id"]),
|
||||
session_status="running",
|
||||
)
|
||||
if cascade_downstream and applied:
|
||||
applied.extend(
|
||||
await self._cascade_downstream(
|
||||
run,
|
||||
plan,
|
||||
latest,
|
||||
artifacts,
|
||||
roots=[node_id for node_id in node_ids if node_id in applied],
|
||||
command_id=command_id,
|
||||
)
|
||||
)
|
||||
return applied
|
||||
|
||||
async def _cascade_downstream(
|
||||
self,
|
||||
run: dict[str, Any],
|
||||
plan: dict[str, Any],
|
||||
latest: dict[str, dict[str, Any]],
|
||||
artifacts: list[dict[str, Any]],
|
||||
*,
|
||||
roots: list[str],
|
||||
command_id: str,
|
||||
) -> list[str]:
|
||||
"""Supersede transitive dependents of ``roots`` and re-plan them.
|
||||
|
||||
Planned/reopened dependents are left alone — they have produced
|
||||
nothing yet and pick up the fresh upstream artifacts when queued.
|
||||
Like the rework target itself, user-directed cascade re-planning is
|
||||
not budget-limited (monotonic attempt number).
|
||||
"""
|
||||
|
||||
deps = _dependency_map(plan)
|
||||
specs = _plan_nodes(plan)
|
||||
dependents: set[str] = set()
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for node_id, parents in deps.items():
|
||||
if node_id in dependents or node_id in roots:
|
||||
continue
|
||||
if any(parent in dependents or parent in roots for parent in parents):
|
||||
dependents.add(node_id)
|
||||
changed = True
|
||||
cascaded: list[str] = []
|
||||
for node_id in sorted(dependents):
|
||||
node = latest.get(node_id)
|
||||
spec = specs.get(node_id)
|
||||
if node is None or spec is None:
|
||||
continue
|
||||
status = str(node.get("status") or "")
|
||||
if status in {"planned", "reopened", "superseded", "cancelled", "cancel_requested", ""}:
|
||||
continue
|
||||
require_transition(status, "superseded")
|
||||
node["status"] = "superseded"
|
||||
node["ended_at"] = iso_now()
|
||||
node["awaiting_input"] = None
|
||||
await self._store.save_node_run(node)
|
||||
for artifact in artifacts:
|
||||
if (
|
||||
artifact.get("node_id") == node_id
|
||||
and int(artifact.get("attempt") or 0) == int(node.get("attempt") or 0)
|
||||
and not artifact.get("superseded_by")
|
||||
):
|
||||
await self._board.mark(artifact, validation_status="superseded", superseded_by=f"command:{command_id}")
|
||||
await self._store.append_event(
|
||||
session_id=str(run["session_id"]),
|
||||
run_id=str(run["id"]),
|
||||
event_type="node.status.changed",
|
||||
data={"node_id": node_id, "node_run_id": node["node_run_id"], "status": "superseded", "reason": "downstream_cascade"},
|
||||
)
|
||||
nxt = int(node.get("attempt") or 0) + 1
|
||||
await self._store.save_node_run(
|
||||
{
|
||||
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
|
||||
"session_id": run["session_id"],
|
||||
"run_id": run["id"],
|
||||
"node_id": node_id,
|
||||
"attempt": nxt,
|
||||
"status": "planned",
|
||||
"label": node.get("label") or spec.get("label") or node_id,
|
||||
"role_display_name": node.get("role_display_name") or spec.get("role_display_name"),
|
||||
"angle": node.get("angle") or spec.get("angle"),
|
||||
"contract_json": node.get("contract_json"),
|
||||
"input_artifact_ids": [],
|
||||
"command_overlay_revision": int(node.get("command_overlay_revision") or 0),
|
||||
"agent_snapshot": dict(node.get("agent_snapshot") or {}),
|
||||
"error": None,
|
||||
"started_at": None,
|
||||
"ended_at": None,
|
||||
}
|
||||
)
|
||||
await self._store.append_event(
|
||||
session_id=str(run["session_id"]),
|
||||
run_id=str(run["id"]),
|
||||
event_type="node.attempt.created",
|
||||
data={
|
||||
"node_id": node_id,
|
||||
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
|
||||
"attempt": nxt,
|
||||
"reason": "downstream_cascade",
|
||||
"command_id": command_id,
|
||||
},
|
||||
)
|
||||
cascaded.append(node_id)
|
||||
return cascaded
|
||||
|
||||
@staticmethod
|
||||
def _add_command_overlay(node: dict[str, Any], *, command_id: str, instruction: str) -> None:
|
||||
snapshot = dict(node.get("agent_snapshot") or {})
|
||||
@@ -622,8 +731,18 @@ class TaskLedger:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def refresh_run_terminal(self, run_id: str) -> str:
|
||||
"""Public terminal-state evaluation; safe to call repeatedly."""
|
||||
|
||||
return await self._refresh_run_terminal(run_id)
|
||||
|
||||
async def _refresh_run_terminal(self, run_id: str) -> str:
|
||||
_plan, run, latest, _artifacts = await self._load(run_id)
|
||||
plan, run, latest, _artifacts = await self._load(run_id)
|
||||
run_status = str(run.get("status") or "running")
|
||||
if run_status in _TERMINAL_RUN_STATUSES:
|
||||
# Idempotency guard: never re-finalize (the finalizer would run
|
||||
# twice when the executor's wave loop re-checks after completion).
|
||||
return run_status
|
||||
statuses = [item["status"] for item in latest.values()]
|
||||
if statuses and all(status == "completed" for status in statuses):
|
||||
from app.report_collaboration.reporting.finalizer import ReportFinalizationError, ReportFinalizer
|
||||
@@ -636,11 +755,32 @@ class TaskLedger:
|
||||
await self._store.patch_run(run_id, status="completed", active_dedupe_key=None, session_status="completed")
|
||||
return "completed"
|
||||
if any(status in _BLOCKING or status == "queued" for status in statuses):
|
||||
return str(run.get("status") or "running")
|
||||
return run_status
|
||||
if any(status == "failed" for status in statuses):
|
||||
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
|
||||
await self._fail_run(run_id, run, reason="node_failed")
|
||||
return "failed"
|
||||
return str(run.get("status") or "running")
|
||||
# Zombie guard: an ``interrupted`` node whose attempt budget is
|
||||
# exhausted can never run again — the ledger only queues
|
||||
# planned/reopened attempts and recovery only opens a new attempt
|
||||
# while attempt < max_attempts. Without this, the run would stay
|
||||
# "running" forever while the dispatcher keeps re-claiming it.
|
||||
specs = _plan_nodes(plan)
|
||||
for node_id, item in latest.items():
|
||||
if str(item.get("status")) != "interrupted":
|
||||
continue
|
||||
if int(item.get("attempt") or 1) >= _node_max_attempts(specs.get(node_id), item):
|
||||
await self._fail_run(run_id, run, reason="node_attempts_exhausted", node_id=node_id)
|
||||
return "failed"
|
||||
return run_status
|
||||
|
||||
async def _fail_run(self, run_id: str, run: dict[str, Any], *, reason: str, node_id: str | None = None) -> None:
|
||||
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
|
||||
await self._store.append_event(
|
||||
session_id=str(run.get("session_id") or ""),
|
||||
run_id=run_id,
|
||||
event_type="run.status.changed",
|
||||
data={"status": "failed", "reason": reason, **({"node_id": node_id} if node_id else {})},
|
||||
)
|
||||
|
||||
async def _load(self, run_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, dict[str, Any]], list[dict[str, Any]]]:
|
||||
run = await self._store.get_run(run_id)
|
||||
@@ -666,6 +806,20 @@ def _plan_nodes(plan: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
return {str(item["id"]): item for item in (plan.get("nodes") or []) if item.get("id")}
|
||||
|
||||
|
||||
def _node_max_attempts(spec: dict[str, Any] | None, node_record: dict[str, Any]) -> int:
|
||||
"""Same precedence as ``_queue_node``: node contract, then plan spec, then 3."""
|
||||
|
||||
contract = node_record.get("contract_json") or {}
|
||||
if isinstance(contract, str):
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
contract = _json.loads(contract)
|
||||
except Exception:
|
||||
contract = {}
|
||||
return int(contract.get("max_attempts") or (spec or {}).get("max_attempts") or 3)
|
||||
|
||||
|
||||
def _dependency_map(plan: dict[str, Any]) -> dict[str, list[str]]:
|
||||
deps: dict[str, list[str]] = {node_id: list(spec.get("depends_on") or []) for node_id, spec in _plan_nodes(plan).items()}
|
||||
for edge in plan.get("edges") or []:
|
||||
|
||||
+5
-2
@@ -195,8 +195,11 @@ class WaveExecutor:
|
||||
await self._command_processor.process_safe_point(run_id)
|
||||
wave = await self._ledger.ready_assignments(run_id)
|
||||
if not wave:
|
||||
run = await self._store.get_run(run_id)
|
||||
return str((run or {}).get("status") or "queued")
|
||||
# Nothing runnable. Evaluate terminal state: all-completed
|
||||
# finalizes, exhausted-interrupted nodes fail the run (zombie
|
||||
# guard). Idempotent when the run already reached a terminal
|
||||
# status via apply_result.
|
||||
return await self._ledger.refresh_run_terminal(run_id)
|
||||
for assignment in wave:
|
||||
self.dispatched.append(assignment.node_id)
|
||||
result = await self._runner.run(assignment)
|
||||
|
||||
@@ -386,6 +386,7 @@ class RuntimeCommandService:
|
||||
command_id=command["command_id"],
|
||||
instruction=instruction,
|
||||
node_ids=impact_nodes,
|
||||
cascade_downstream=True,
|
||||
)
|
||||
if not changed:
|
||||
raise ReportCollaborationValidationError("COMMAND_NO_EFFECT", "目标节点已过期或命令已经应用")
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
"""规划师智能体(PlannerAgent)——真智能体,不是规则路由 + 模板文案。
|
||||
|
||||
设计 v2 的需求对话与工作流生成主路径:
|
||||
- ``converse``:与用户多轮自由对话确认需求。回复文案(含开场)完全由
|
||||
智能体生成并直接落库展示 —— 「收到您的报告需求。为了给出…」这类
|
||||
死模板文案已从该路径移除。
|
||||
- ``design_workflow``:带着**完整对话上下文**(规划师问了什么、用户
|
||||
答了什么)生成受词表约束的自定义工作流(``StrategyRoleSet``),
|
||||
与确定性模板走同一条 装配 → 校验 → 绑定 链路。
|
||||
|
||||
规则路由与模板只作为模型彻底不可用时的最后防线,不出现在用户面前。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from app.report_collaboration.agentscope_runtime.model_resolver import ROLE_MODEL_FIELD, RoleModelSnapshot, resolve_role_model
|
||||
from app.report_collaboration.contracts.plans import RoleDemand
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.planning.controller import RoleSlot, StrategyRoleSet
|
||||
from app.report_collaboration.requirement.intent_router import _merge_snapshot, apply_default_assumptions
|
||||
from deerflow.config.app_config import AppConfig, get_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 词表(结构化输出的安全边界,LLM 不受约束的自由只在文案层) ─────────────
|
||||
|
||||
MEMBER_ROLES: tuple[str, ...] = tuple(key for key in ROLE_MODEL_FIELD if key != "coordinator")
|
||||
|
||||
ROLE_ARTIFACTS: dict[str, tuple[str, ...]] = {
|
||||
"researcher": ("EvidenceBundle",),
|
||||
"verifier": ("VerificationResult",),
|
||||
"analyst": ("AngleAnalysis",),
|
||||
"synthesizer": ("SynthesisOutline",),
|
||||
"writer": ("ReportSectionDraft",),
|
||||
"reviewer": ("ReviewDecision",),
|
||||
"reviser": ("ReportSectionDraft",),
|
||||
}
|
||||
|
||||
QUALITY_GATES: tuple[str, ...] = ("cite_required", "dual_verification", "independent_review", "coverage_matrix")
|
||||
|
||||
# ── 智能体输出契约 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PlannerTurn(BaseModel):
|
||||
"""规划师一轮对话的完整产出:自由文案 + 结构化状态。"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
action: str = "clarify"
|
||||
reply: str = ""
|
||||
questions: list[str] = Field(default_factory=list)
|
||||
impact: list[str] = Field(default_factory=list)
|
||||
assumptions: list[str] = Field(default_factory=list)
|
||||
requirement: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StrategyNodeDraft(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
slot_id: str
|
||||
role_key: str
|
||||
display_name: str
|
||||
angle: str | None = None
|
||||
mission: str
|
||||
depends_on: list[str] = Field(default_factory=list)
|
||||
output_artifact_type: str
|
||||
suggested_tools: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StrategyDraft(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
reply: str = ""
|
||||
strategy: str = "custom"
|
||||
title: str
|
||||
summary: str
|
||||
rationale: str
|
||||
estimated_duration_seconds: int = 900
|
||||
estimated_cost_level: str = "medium"
|
||||
quality_gate_ids: list[str] = Field(default_factory=lambda: ["cite_required"])
|
||||
nodes: list[StrategyNodeDraft] = Field(default_factory=list)
|
||||
|
||||
|
||||
LlmFactory = Callable[[], Any]
|
||||
|
||||
|
||||
def planner_model_snapshot(app_config: AppConfig | None = None) -> RoleModelSnapshot:
|
||||
"""规划师身份沿用角色解析链:coordinator_model → planner_model → 默认模型。"""
|
||||
|
||||
return resolve_role_model("coordinator", app_config=app_config or get_app_config())
|
||||
|
||||
|
||||
def build_planner_llm(app_config: AppConfig | None = None) -> Any:
|
||||
from deerflow.models.factory import create_chat_model
|
||||
|
||||
snapshot = planner_model_snapshot(app_config)
|
||||
return create_chat_model(snapshot.config_name, app_config=app_config or get_app_config())
|
||||
|
||||
|
||||
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict[str, Any] | None:
|
||||
if not text:
|
||||
return None
|
||||
# 思考模型(MiniMax-M2.7 等)回复可能带 <think>…</think>;JSON 在思考块之后。
|
||||
# 先剥思考块提取;剥完没有再回退用全文(极端情况 JSON 混在思考内)。
|
||||
for candidate in (_THINK_BLOCK_RE.sub("", text).strip(), text.strip()):
|
||||
if not candidate:
|
||||
continue
|
||||
cleaned = candidate
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(cleaned[start : end + 1])
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
async def _invoke_json(llm: Any, messages: list[Any]) -> tuple[dict[str, Any] | None, str]:
|
||||
"""调用模型并提取 JSON。返回 (解析结果, 原始文本);解析失败结果为 None。"""
|
||||
|
||||
try:
|
||||
response = await llm.ainvoke(messages)
|
||||
raw = getattr(response, "content", response)
|
||||
if isinstance(raw, list):
|
||||
raw = "".join(str(getattr(block, "text", block) if not isinstance(block, str) else block) for block in raw)
|
||||
raw = str(raw)
|
||||
return _extract_json(raw), raw
|
||||
except Exception as exc: # noqa: BLE001 — 智能体故障必须显式失败
|
||||
logger.warning("rc planner agent llm call failed: %s: %s", exc.__class__.__name__, exc)
|
||||
return None, ""
|
||||
|
||||
|
||||
async def _invoke_json_with_retry(llm: Any, system: str, user_prompt: str) -> dict[str, Any] | None:
|
||||
"""带一次格式重试:模型输出不含 JSON 时,附原始输出要求只出 JSON 再问一次。"""
|
||||
|
||||
parsed, raw = await _invoke_json(llm, [("system", system), ("human", user_prompt)])
|
||||
if parsed is None:
|
||||
logger.warning("rc planner reply without JSON (raw head: %.200s)", raw[:200])
|
||||
retry_prompt = (
|
||||
user_prompt
|
||||
+ "\n\n【格式提醒】你上一轮的输出没有包含合法 JSON。这次必须只输出一个 JSON 对象,"
|
||||
+ f"不要在 JSON 之外输出任何文字。你上一轮的输出是:{raw[:500]}"
|
||||
)
|
||||
parsed, _ = await _invoke_json(llm, [("system", system), ("human", retry_prompt)])
|
||||
return parsed
|
||||
|
||||
|
||||
# ── 规划师智能体 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PlannerAgent:
|
||||
"""需求对话与工作流设计的唯一主路径(真智能体)。"""
|
||||
|
||||
def __init__(self, llm: Any, *, app_config: AppConfig | None = None) -> None:
|
||||
self._llm = llm
|
||||
self._app_config = app_config
|
||||
|
||||
# ── 需求对话 ───────────────────────────────────────────────────────────
|
||||
|
||||
async def converse(
|
||||
self,
|
||||
*,
|
||||
user_text: str,
|
||||
history: list[dict[str, str]],
|
||||
previous_snapshot: dict[str, Any] | None,
|
||||
previous_revision: int,
|
||||
clarification_round: int,
|
||||
max_rounds: int,
|
||||
) -> PlannerTurn | None:
|
||||
"""一轮规划对话。返回 None 表示智能体不可用(调用方走最后防线)。"""
|
||||
|
||||
forced = clarification_round >= max_rounds
|
||||
system = _CONVERSE_SYSTEM.format(
|
||||
roles=", ".join(MEMBER_ROLES),
|
||||
forced_note=(
|
||||
"\n\n【重要】澄清轮次已达上限,本轮你必须判定需求就绪(action=ready):"
|
||||
"自然地告诉用户你将按当前理解开始规划,把未能确认的点写进 assumptions。"
|
||||
if forced
|
||||
else ""
|
||||
),
|
||||
)
|
||||
known = {k: v for k, v in (previous_snapshot or {}).items() if v not in (None, "", [])}
|
||||
known.pop("revision", None)
|
||||
history_text = "\n".join(
|
||||
f"{'用户' if item.get('role') == 'human' else '规划师'}: {str(item.get('content') or '')[:400]}"
|
||||
for item in history[-10:]
|
||||
)
|
||||
user_prompt = (
|
||||
f"当前已确认的需求快照(JSON):{json.dumps(known, ensure_ascii=False)}\n\n"
|
||||
f"最近的对话:\n{history_text or '(无)'}\n\n"
|
||||
f"用户最新消息:{user_text}"
|
||||
)
|
||||
parsed = await _invoke_json_with_retry(self._llm, system, user_prompt)
|
||||
if parsed is None:
|
||||
return None
|
||||
try:
|
||||
turn = PlannerTurn.model_validate(parsed)
|
||||
except ValidationError:
|
||||
logger.warning("rc planner turn invalid: %s", str(parsed)[:200])
|
||||
return None
|
||||
if not (turn.reply or "").strip():
|
||||
return None
|
||||
if forced and turn.action != "ready":
|
||||
turn.action = "ready"
|
||||
if turn.action == "ready" and not turn.assumptions:
|
||||
turn.assumptions = ["按对话中已确认的信息直接规划"]
|
||||
return turn
|
||||
|
||||
def merge_requirement(
|
||||
self,
|
||||
turn: PlannerTurn,
|
||||
*,
|
||||
previous_snapshot: dict[str, Any] | None,
|
||||
previous_revision: int,
|
||||
) -> ReportRequirementSnapshot:
|
||||
"""把智能体本轮带回的需求字段合并进既有快照(revision 语义同规则路由)。"""
|
||||
|
||||
known = {k: v for k, v in (previous_snapshot or {}).items() if k != "revision"}
|
||||
# LLM 契约是「只填能确认的字段」;再过滤一次空值,避免空串覆盖已确认信息。
|
||||
patch = {
|
||||
k: v
|
||||
for k, v in (turn.requirement or {}).items()
|
||||
if k != "revision" and v not in (None, "", [])
|
||||
}
|
||||
revision = (
|
||||
max(1, int(previous_revision) + 1)
|
||||
if (previous_snapshot or patch)
|
||||
else max(1, int(previous_revision) or 1)
|
||||
)
|
||||
if not previous_snapshot:
|
||||
revision = 1
|
||||
merged = _merge_snapshot(known, patch, revision=revision)
|
||||
if turn.action == "ready":
|
||||
merged = apply_default_assumptions(merged)[0]
|
||||
return merged
|
||||
|
||||
# ── 工作流设计 ─────────────────────────────────────────────────────────
|
||||
|
||||
async def design_workflow(
|
||||
self,
|
||||
*,
|
||||
requirement: ReportRequirementSnapshot,
|
||||
history: list[dict[str, str]],
|
||||
max_team_members: int = 8,
|
||||
) -> tuple[StrategyRoleSet, str] | None:
|
||||
"""带着完整对话上下文设计工作流;返回 (策略, 展示文案) 或 None。"""
|
||||
|
||||
fields = {k: v for k, v in requirement.model_dump().items() if v not in (None, "", [])}
|
||||
fields.pop("revision", None)
|
||||
history_text = "\n".join(
|
||||
f"{'用户' if item.get('role') == 'human' else '规划师'}: {str(item.get('content') or '')[:300]}"
|
||||
for item in history[-12:]
|
||||
)
|
||||
system = _DESIGN_SYSTEM.format(
|
||||
roles=", ".join(MEMBER_ROLES),
|
||||
artifacts="\n".join(f" - {role}: {' / '.join(arts)}" for role, arts in ROLE_ARTIFACTS.items()),
|
||||
gates=", ".join(QUALITY_GATES),
|
||||
max_nodes=min(max_team_members, 8),
|
||||
)
|
||||
user_prompt = (
|
||||
f"需求快照(JSON):{json.dumps(fields, ensure_ascii=False)}\n\n"
|
||||
f"需求确认阶段的对话(你与用户):\n{history_text or '(无)'}\n\n请设计工作流。"
|
||||
)
|
||||
parsed = await _invoke_json_with_retry(self._llm, system, user_prompt)
|
||||
if parsed is None:
|
||||
return None
|
||||
try:
|
||||
draft = StrategyDraft.model_validate(parsed)
|
||||
except ValidationError:
|
||||
logger.warning("rc planner strategy draft invalid: %s", str(parsed)[:200])
|
||||
return None
|
||||
strategy = _validate_strategy_dag(draft)
|
||||
if strategy is None:
|
||||
logger.warning("rc planner strategy failed vocabulary/DAG validation: %s", str(parsed)[:200])
|
||||
return None
|
||||
reply = (draft.reply or "").strip() or f"已按需求设计好协作工作流「{draft.title}」。"
|
||||
return strategy, reply
|
||||
|
||||
def workflow_reply_fallback(self, strategy: StrategyRoleSet) -> str:
|
||||
return (
|
||||
f"我按确认的需求设计了「{strategy.title}」:{strategy.summary}\n"
|
||||
"可以直接采用开始执行;想调整就告诉我(例如「加一条竞品对比线」)。"
|
||||
)
|
||||
|
||||
|
||||
# ── 提示词 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_CONVERSE_SYSTEM = """你是「报告协作工作台」的规划师智能体。用户想让你组织一个多智能体团队协作产出一份报告,你的任务是先通过自然对话把需求弄清楚,然后(由你自己)设计协作工作流。
|
||||
|
||||
你的对话风格:
|
||||
- 像一位资深顾问:先回应用户刚说的话,再追问。回复里**不要**出现「收到您的报告需求」这类机械开场——用你自己的话自然表达。
|
||||
- 每轮最多追问 2~3 个真正影响方案设计的问题:关注对象与范围、给谁看、必须覆盖的角度、篇幅与深度、时间范围、特殊约束。用户已说清的绝不重复问。
|
||||
- 用户回复后先简短确认你的理解(一两句),再决定是否继续问。
|
||||
- 信息足够时,自然地总结你对需求的把握并判定就绪(action=ready),告诉用户下一步你会为他们设计工作流。
|
||||
|
||||
可用的团队角色(供你构思,不必对用户罗列):{roles}{forced_note}
|
||||
|
||||
每轮严格输出一个 JSON 对象(不要输出 JSON 以外的文字):
|
||||
{{
|
||||
"reply": "给用户看的完整回复(自然对话,直接展示)",
|
||||
"action": "clarify" 或 "ready",
|
||||
"questions": ["追问的问题(与 reply 一致,供系统记录)"],
|
||||
"impact": ["为什么需要问(与 questions 对应)"],
|
||||
"assumptions": ["ready 时:你采用的默认假设"],
|
||||
"requirement": {{"topic": "...", "objective": "...", "audience": "...", "required_angles": ["..."], "time_range": "...", "length_target": "...", "tone": "..."}}
|
||||
}}
|
||||
|
||||
requirement 只填你从对话中能确认的字段(含此前轮次已确认的),其余省略。reply 用中文。"""
|
||||
|
||||
_DESIGN_SYSTEM = """你是「报告协作工作台」的规划师智能体。需求已经与你确认完毕,现在请设计多智能体协作工作流。
|
||||
|
||||
词表约束(结构化部分必须严格遵守):
|
||||
- role_key 只能取:{roles}
|
||||
- 各角色的 output_artifact_type 只能取:
|
||||
{artifacts}
|
||||
- quality_gate_ids 只能取:{gates}
|
||||
|
||||
结构要求:
|
||||
- 2~{max_nodes} 个节点;至少 1 个 writer;正式报告建议加 reviewer。
|
||||
- 研究线(researcher)按角度并行;research → verify → analyze → write → review 是常见主干,可按需求裁剪。
|
||||
- depends_on 引用其他节点的 slot_id;禁止环;至少一个节点 depends_on 为空(起点)。
|
||||
- slot_id 用简短英文(如 research-market / write-main)。mission 写清楚该节点做什么、产出什么。
|
||||
|
||||
严格输出 JSON(不要输出 JSON 以外的文字):
|
||||
{{
|
||||
"reply": "给用户看的介绍:说明你为什么这样设计(对应需求的哪些要求),一两句即可",
|
||||
"strategy": "custom",
|
||||
"title": "方案标题",
|
||||
"summary": "一句话概述团队构成与链路",
|
||||
"rationale": "设计理由",
|
||||
"estimated_duration_seconds": 1500,
|
||||
"estimated_cost_level": "low|medium|high",
|
||||
"quality_gate_ids": ["cite_required"],
|
||||
"nodes": [
|
||||
{{"slot_id": "research-market", "role_key": "researcher", "display_name": "研究员", "angle": "市场份额", "mission": "...", "depends_on": [], "output_artifact_type": "EvidenceBundle", "suggested_tools": ["web_search"]}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
|
||||
# ── 工作流草稿校验(词表 + DAG) ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_strategy_dag(draft: StrategyDraft) -> StrategyRoleSet | None:
|
||||
nodes = draft.nodes
|
||||
if not 2 <= len(nodes) <= 8:
|
||||
return None
|
||||
slot_ids = {node.slot_id for node in nodes}
|
||||
if len(slot_ids) != len(nodes):
|
||||
return None
|
||||
if not any(node.role_key == "writer" for node in nodes):
|
||||
return None
|
||||
for node in nodes:
|
||||
if node.role_key not in MEMBER_ROLES:
|
||||
return None
|
||||
if node.output_artifact_type not in ROLE_ARTIFACTS.get(node.role_key, ()):
|
||||
return None
|
||||
for dep in node.depends_on:
|
||||
if dep not in slot_ids or dep == node.slot_id:
|
||||
return None
|
||||
indegree = {node.slot_id: len(node.depends_on) for node in nodes}
|
||||
children: dict[str, list[str]] = {node.slot_id: [] for node in nodes}
|
||||
for node in nodes:
|
||||
for dep in node.depends_on:
|
||||
children[dep].append(node.slot_id)
|
||||
queue = [slot for slot, degree in indegree.items() if degree == 0]
|
||||
seen = 0
|
||||
while queue:
|
||||
current = queue.pop()
|
||||
seen += 1
|
||||
for child in children[current]:
|
||||
indegree[child] -= 1
|
||||
if indegree[child] == 0:
|
||||
queue.append(child)
|
||||
if seen != len(nodes):
|
||||
return None
|
||||
|
||||
gates = [gate for gate in draft.quality_gate_ids if gate in QUALITY_GATES] or ["cite_required"]
|
||||
return StrategyRoleSet(
|
||||
strategy="custom" if draft.strategy == "custom" else f"custom_{draft.strategy[:24]}",
|
||||
title=draft.title[:80],
|
||||
summary=draft.summary[:200],
|
||||
rationale=draft.rationale[:300],
|
||||
estimated_duration_seconds=max(300, min(int(draft.estimated_duration_seconds or 1500), 7200)),
|
||||
estimated_cost_level=draft.estimated_cost_level if draft.estimated_cost_level in {"low", "medium", "high"} else "medium",
|
||||
recommended=True,
|
||||
slots=[
|
||||
RoleSlot(
|
||||
slot_id=node.slot_id,
|
||||
demand=RoleDemand(
|
||||
role_key=node.role_key,
|
||||
display_name=node.display_name[:32] or node.role_key,
|
||||
angle=(node.angle or None),
|
||||
mission=node.mission[:400],
|
||||
depends_on_roles=[],
|
||||
output_artifact_type=node.output_artifact_type,
|
||||
suggested_tools=[tool for tool in node.suggested_tools if tool][:4],
|
||||
),
|
||||
depends_on=list(node.depends_on),
|
||||
)
|
||||
for node in nodes
|
||||
],
|
||||
quality_gate_ids=gates,
|
||||
)
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Fulfill a durable plan_request: catalog → select → assemble → validate → persist.
|
||||
|
||||
Does not start a run. Rule-based until the BE-7 model adapter is wired.
|
||||
Does not start a run. P1.2 起优先由规划师 LLM 生成定制策略,模板为回退。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.planning.assembler import PlanAssembler
|
||||
from app.report_collaboration.planning.catalog import project_agent_catalog
|
||||
from app.report_collaboration.planning.controller import PlanController
|
||||
from app.report_collaboration.planning.planner_agent import LlmFactory
|
||||
from app.report_collaboration.planning.validator import apply_validation, validate_assembled_plan
|
||||
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
|
||||
from deerflow.config.app_config import get_app_config
|
||||
@@ -22,6 +23,8 @@ from deerflow.persistence.report_collaboration import (
|
||||
ReportCollaborationValidationError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BUSY = frozenset({"running", "awaiting_input", "reviewing", "completed", "failed", "cancelled"})
|
||||
|
||||
|
||||
@@ -51,12 +54,15 @@ class PlanProposalService:
|
||||
*,
|
||||
agent_store: Any | None = None,
|
||||
config: ReportCollaborationConfig | None = None,
|
||||
llm_factory: LlmFactory | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._agent_store = agent_store
|
||||
self._config = config
|
||||
self._controller = PlanController()
|
||||
self._assembler = PlanAssembler()
|
||||
self._llm_factory = llm_factory
|
||||
self._llm: Any | None = None
|
||||
self._llm_tried = False
|
||||
|
||||
def _cfg(self) -> ReportCollaborationConfig:
|
||||
return self._config or get_app_config().report_collaboration
|
||||
@@ -110,12 +116,29 @@ class PlanProposalService:
|
||||
requirement = requirement_from_session(record)
|
||||
cfg = self._cfg()
|
||||
catalog = await project_agent_catalog(user_id=user_id, config=cfg, agent_store=self._agent_store)
|
||||
strategies = self._controller.propose(
|
||||
requirement,
|
||||
max_parallel_tasks=cfg.max_parallel_tasks,
|
||||
group_id = f"rpg_{uuid4().hex[:12]}"
|
||||
|
||||
# P1-R:规划师智能体是唯一路径 —— 带着完整对话上下文设计工作流。
|
||||
# 模型不可用即无法继续(无模板兜底)。
|
||||
from app.report_collaboration.planning.planner_agent import PlannerAgent
|
||||
|
||||
llm = self._planner_llm()
|
||||
history = [
|
||||
{"role": "human" if item.get("role") == "human" else "ai", "content": str(item.get("content") or "")}
|
||||
for item in await self._store.list_messages(session_id)
|
||||
]
|
||||
designed = await PlannerAgent(llm).design_workflow(
|
||||
requirement=requirement,
|
||||
history=history,
|
||||
max_team_members=cfg.max_team_members,
|
||||
)
|
||||
group_id = f"rpg_{uuid4().hex[:12]}"
|
||||
if designed is None:
|
||||
raise ReportCollaborationValidationError(
|
||||
"PLANNER_UNAVAILABLE", "规划师智能体未能生成有效工作流,请重试或换个说法再试"
|
||||
)
|
||||
strategies: list[Any] = [designed[0]]
|
||||
planner_reply: str | None = designed[1]
|
||||
|
||||
assembled_list = [
|
||||
self._assembler.assemble(
|
||||
strategy,
|
||||
@@ -140,13 +163,44 @@ class PlanProposalService:
|
||||
for candidate in persisted:
|
||||
saved.append(await self._store.insert_plan(session_id, candidate.model_dump()))
|
||||
await self._store.update_command_status(command_id, status="completed")
|
||||
content = planner_reply or "工作流已生成,可开始执行。"
|
||||
await self._store.create_message(
|
||||
session_id,
|
||||
role="ai",
|
||||
content="已根据当前需求生成 3 个候选协作方案。请选择其中一个后再开始执行;选择方案不会自动开跑。",
|
||||
content=content,
|
||||
idempotency_key=f"{idempotency_key}:plans",
|
||||
name=COORDINATOR_NAME,
|
||||
metadata={"plan_request_command_id": command_id, "proposal_group_id": group_id},
|
||||
metadata={"plan_request_command_id": command_id, "proposal_group_id": group_id, "plan_source": "planner_agent"},
|
||||
completed=True,
|
||||
)
|
||||
return saved
|
||||
|
||||
def _planner_llm(self) -> Any:
|
||||
"""规划师模型:注入优先(测试);否则按 planner_llm_enabled 懒构建。不可用即抛错。"""
|
||||
|
||||
if self._llm_factory is not None:
|
||||
llm = self._llm_factory()
|
||||
if llm is None:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||
return llm
|
||||
try:
|
||||
if not (self._config or get_app_config().report_collaboration).planner_llm_enabled:
|
||||
raise ReportCollaborationValidationError(
|
||||
"PLANNER_UNAVAILABLE", "规划师智能体未启用(report_collaboration.planner_llm_enabled)"
|
||||
)
|
||||
except ReportCollaborationValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", f"规划师智能体配置不可读:{exc}") from None
|
||||
if not self._llm_tried:
|
||||
self._llm_tried = True
|
||||
try:
|
||||
from app.report_collaboration.planning.planner_agent import build_planner_llm
|
||||
|
||||
self._llm = build_planner_llm()
|
||||
except Exception as exc:
|
||||
logger.warning("rc planner agent llm build failed: %s", exc)
|
||||
self._llm = None
|
||||
if self._llm is None:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||
return self._llm
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Pre-plan requirement resolution package."""
|
||||
|
||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn
|
||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision
|
||||
from app.report_collaboration.requirement.resolver import RequirementResolver
|
||||
|
||||
__all__ = ["PrePlanDecision", "RequirementResolver", "route_pre_plan_turn"]
|
||||
__all__ = ["PrePlanDecision", "RequirementResolver"]
|
||||
+11
-79
@@ -1,4 +1,9 @@
|
||||
"""Pre-plan IntentRouterAgent: understand the user turn, never dispatch work."""
|
||||
"""Pre-plan 数据结构与快照合并工具。
|
||||
|
||||
P1-R 之后需求确认完全由规划师智能体(``planning/planner_agent.py``)驱动;
|
||||
本模块只保留被复用的数据结构与合并工具。原规则路由 ``route_pre_plan_turn``
|
||||
已按设计 v2 移除 —— 模型不可用即报错,不再有规则兜底。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,12 +12,11 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES, DEFAULT_AUDIENCE, DEFAULT_LANGUAGE, DEFAULT_SOURCE_CONSTRAINTS, DEFAULT_TONE, PLAN_AFFECTING_GAPS
|
||||
from app.report_collaboration.requirement.extractor import extract_requirement_patch, wants_direct_start
|
||||
from app.report_collaboration.requirement.defaults import DEFAULT_ANGLES, DEFAULT_AUDIENCE, DEFAULT_LANGUAGE, DEFAULT_SOURCE_CONSTRAINTS, DEFAULT_TONE
|
||||
|
||||
|
||||
class PrePlanDecision(BaseModel):
|
||||
"""Structured output of the pre-plan router. It never builds a graph or starts a run."""
|
||||
"""规划师智能体一轮决策。It never builds a graph or starts a run."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -24,11 +28,13 @@ class PrePlanDecision(BaseModel):
|
||||
used_defaults: bool = False
|
||||
reason: str
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
#: 规划师智能体产出的完整对话文案,直接落库展示。
|
||||
reply: str | None = None
|
||||
|
||||
|
||||
def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, revision: int) -> ReportRequirementSnapshot:
|
||||
base = dict(previous or {})
|
||||
base.update(patch)
|
||||
base.update(patch or {})
|
||||
topic = str(base.get("topic") or "").strip()
|
||||
return ReportRequirementSnapshot(
|
||||
topic=topic or "未指定主题",
|
||||
@@ -49,21 +55,6 @@ def _merge_snapshot(previous: dict[str, Any] | None, patch: dict[str, Any], *, r
|
||||
)
|
||||
|
||||
|
||||
def _gaps(requirement: ReportRequirementSnapshot) -> list[tuple[str, str, str]]:
|
||||
missing: list[tuple[str, str, str]] = []
|
||||
if not requirement.topic or requirement.topic == "未指定主题":
|
||||
missing.append(("topic", "这份报告要写什么主题?请用一两句话说明对象、时间和目的。", "没有主题无法选择方案"))
|
||||
for field, question, impact in PLAN_AFFECTING_GAPS:
|
||||
value = getattr(requirement, field, None)
|
||||
if field == "required_angles":
|
||||
empty = not value
|
||||
else:
|
||||
empty = not (value or "").strip()
|
||||
if empty:
|
||||
missing.append((field, question, impact))
|
||||
return missing
|
||||
|
||||
|
||||
def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[ReportRequirementSnapshot, list[str]]:
|
||||
assumptions: list[str] = []
|
||||
data = requirement.model_dump()
|
||||
@@ -82,62 +73,3 @@ def apply_default_assumptions(requirement: ReportRequirementSnapshot) -> tuple[R
|
||||
data["tone"] = DEFAULT_TONE
|
||||
assumptions.append(DEFAULT_TONE)
|
||||
return ReportRequirementSnapshot.model_validate(data), assumptions
|
||||
|
||||
|
||||
def route_pre_plan_turn(
|
||||
text: str,
|
||||
*,
|
||||
previous_snapshot: dict[str, Any] | None,
|
||||
previous_revision: int,
|
||||
clarification_round: int,
|
||||
max_clarification_rounds: int,
|
||||
) -> PrePlanDecision:
|
||||
"""Rule-based pre-plan routing. Does not call tools or create plans."""
|
||||
|
||||
patch = extract_requirement_patch(text, previous=previous_snapshot)
|
||||
revision = max(1, int(previous_revision) + 1) if previous_snapshot or patch else max(1, int(previous_revision) or 1)
|
||||
if not previous_snapshot:
|
||||
revision = 1
|
||||
requirement = _merge_snapshot(previous_snapshot, patch, revision=revision)
|
||||
skip = wants_direct_start(text)
|
||||
hit_cap = clarification_round >= max_clarification_rounds
|
||||
gaps = _gaps(requirement)
|
||||
|
||||
if requirement.topic == "未指定主题" and skip:
|
||||
return PrePlanDecision(
|
||||
action="clarify",
|
||||
requirement=requirement,
|
||||
questions=["这份报告要写什么主题?请用一两句话说明对象、时间和目的。"],
|
||||
impact=["没有主题无法选择方案"],
|
||||
reason="用户要求直接开始,但还没有主题",
|
||||
confidence=0.9,
|
||||
)
|
||||
|
||||
if skip or (hit_cap and gaps):
|
||||
filled, assumptions = apply_default_assumptions(requirement)
|
||||
return PrePlanDecision(
|
||||
action="ready",
|
||||
requirement=filled,
|
||||
assumptions=assumptions,
|
||||
used_defaults=True,
|
||||
reason="用户要求按默认假设继续" if skip else "已达澄清上限,使用明确假设继续",
|
||||
confidence=0.86 if skip else 0.7,
|
||||
)
|
||||
|
||||
if gaps:
|
||||
return PrePlanDecision(
|
||||
action="clarify",
|
||||
requirement=requirement,
|
||||
questions=[item[1] for item in gaps],
|
||||
impact=[item[2] for item in gaps],
|
||||
reason="缺少真正影响方案选择的信息",
|
||||
confidence=0.8,
|
||||
)
|
||||
|
||||
return PrePlanDecision(
|
||||
action="ready",
|
||||
requirement=requirement,
|
||||
reason="必要信息已齐,可以进入规划",
|
||||
confidence=0.84,
|
||||
used_defaults=False,
|
||||
)
|
||||
+142
-67
@@ -1,18 +1,39 @@
|
||||
"""Validate and persist requirement revisions. Never starts planning or a run."""
|
||||
"""Requirement resolver: the planner agent is the only path.
|
||||
|
||||
模型不可用即无法继续 —— 直接抛 ``PLANNER_UNAVAILABLE``,没有任何规则/
|
||||
模板兜底(设计 v2:需求确认必须由真智能体完成)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.report_collaboration.contracts.clarifications import ClarificationRecord
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.requirement.defaults import COORDINATOR_NAME, DEFAULT_TITLE
|
||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision, route_pre_plan_turn
|
||||
from app.report_collaboration.requirement.intent_router import PrePlanDecision
|
||||
from deerflow.config.app_config import get_app_config
|
||||
from deerflow.persistence.report_collaboration import ReportCollaborationStore
|
||||
from deerflow.persistence.report_collaboration import (
|
||||
ReportCollaborationStore,
|
||||
ReportCollaborationValidationError,
|
||||
)
|
||||
from deerflow.persistence.report_collaboration.codec import iso_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 仅供类型注解;运行时惰性导入避免 requirement ↔ planning 循环依赖。
|
||||
LlmFactory = Any
|
||||
|
||||
|
||||
def _title_from_topic(topic: str, current: str | None) -> str | None:
|
||||
if current and current != DEFAULT_TITLE:
|
||||
return None
|
||||
cleaned = (topic or "").strip()
|
||||
if not cleaned or cleaned == "未指定主题":
|
||||
return None
|
||||
return cleaned[:40]
|
||||
|
||||
|
||||
def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = record.get("requirement_json")
|
||||
@@ -35,58 +56,56 @@ def _state_from_session(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"snapshot": None, "clarifications": [], "assumptions": [], "clarification_round": 0, "ready_to_plan": False}
|
||||
|
||||
|
||||
def _dump_state(
|
||||
*,
|
||||
snapshot: ReportRequirementSnapshot,
|
||||
clarifications: list[dict[str, Any]],
|
||||
assumptions: list[str],
|
||||
clarification_round: int,
|
||||
ready_to_plan: bool,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"snapshot": snapshot.model_dump(),
|
||||
"clarifications": clarifications,
|
||||
"assumptions": assumptions,
|
||||
"clarification_round": clarification_round,
|
||||
"ready_to_plan": ready_to_plan,
|
||||
}
|
||||
|
||||
|
||||
def format_clarification_message(questions: list[str], *, allow_skip: bool = True) -> str:
|
||||
numbered = "\n".join(f"{index}. {question}" for index, question in enumerate(questions, start=1))
|
||||
suffix = "\n\n也可以直接回复「直接开始」,我会按默认假设记录需求。澄清完成不会自动生成方案。" if allow_skip else ""
|
||||
return f"收到您的报告需求。为了给出最合适的协作方案,请先确认:\n\n{numbered}{suffix}"
|
||||
|
||||
|
||||
def format_ready_message(decision: PrePlanDecision) -> str:
|
||||
req = decision.requirement
|
||||
angles = "、".join(req.required_angles) if req.required_angles else "(未指定)"
|
||||
lines = [
|
||||
f"已写入需求版本 v{req.revision}。",
|
||||
f"主题:{req.topic}",
|
||||
f"读者:{req.audience or '未指定'}",
|
||||
f"角度:{angles}",
|
||||
]
|
||||
if decision.assumptions:
|
||||
lines.append("默认假设:" + ";".join(decision.assumptions) + "。")
|
||||
lines.append("澄清完成不等于已经生成候选方案,需要显式发起规划后才会出现方案卡。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _title_from_topic(topic: str, current: str | None) -> str | None:
|
||||
if current and current != DEFAULT_TITLE:
|
||||
return None
|
||||
cleaned = (topic or "").strip()
|
||||
if not cleaned or cleaned == "未指定主题":
|
||||
return None
|
||||
return cleaned[:40]
|
||||
|
||||
|
||||
class RequirementResolver:
|
||||
"""Writes requirement revisions and clarification records. Does not enqueue plans."""
|
||||
|
||||
def __init__(self, store: ReportCollaborationStore) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
store: ReportCollaborationStore,
|
||||
*,
|
||||
planner_llm_factory: LlmFactory | None = None,
|
||||
config: Any | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._planner_llm_factory = planner_llm_factory
|
||||
self._config = config
|
||||
self._default_llm: Any = None
|
||||
self._default_llm_tried = False
|
||||
|
||||
def _planner_llm(self) -> Any:
|
||||
"""规划师模型:注入优先(测试);否则按 planner_llm_enabled 懒构建。不可用即抛错。"""
|
||||
|
||||
if self._planner_llm_factory is not None:
|
||||
llm = self._planner_llm_factory()
|
||||
if llm is None:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||
return llm
|
||||
try:
|
||||
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
|
||||
|
||||
cfg: Any = self._config
|
||||
if cfg is None:
|
||||
cfg = get_app_config().report_collaboration
|
||||
if not ReportCollaborationConfig.model_validate(cfg).planner_llm_enabled:
|
||||
raise ReportCollaborationValidationError(
|
||||
"PLANNER_UNAVAILABLE", "规划师智能体未启用(report_collaboration.planner_llm_enabled)"
|
||||
)
|
||||
except ReportCollaborationValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", f"规划师智能体配置不可读:{exc}") from None
|
||||
if not self._default_llm_tried:
|
||||
self._default_llm_tried = True
|
||||
try:
|
||||
from app.report_collaboration.planning.planner_agent import build_planner_llm
|
||||
|
||||
self._default_llm = build_planner_llm()
|
||||
except Exception as exc:
|
||||
logger.warning("rc planner agent llm build failed: %s", exc)
|
||||
self._default_llm = None
|
||||
if self._default_llm is None:
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体不可用,请检查模型配置")
|
||||
return self._default_llm
|
||||
|
||||
async def handle_user_message(
|
||||
self,
|
||||
@@ -111,16 +130,67 @@ class RequirementResolver:
|
||||
cfg_rounds = max_clarification_rounds
|
||||
if cfg_rounds is None:
|
||||
cfg_rounds = int(get_app_config().report_collaboration.max_clarification_rounds)
|
||||
decision = route_pre_plan_turn(
|
||||
str(user_message.get("content") or ""),
|
||||
previous_snapshot=state["snapshot"],
|
||||
|
||||
text = str(user_message.get("content") or "")
|
||||
history = [
|
||||
{"role": "human" if item.get("role") == "human" else "ai", "content": str(item.get("content") or "")}
|
||||
for item in messages
|
||||
if item.get("message_id") != user_message["message_id"]
|
||||
]
|
||||
|
||||
decision = await self._agent_decision(
|
||||
text,
|
||||
history=history,
|
||||
state=state,
|
||||
previous_revision=int(record.get("requirement_revision") or 0),
|
||||
clarification_round=int(state["clarification_round"] or 0),
|
||||
max_clarification_rounds=cfg_rounds,
|
||||
max_rounds=cfg_rounds,
|
||||
)
|
||||
if decision is None:
|
||||
# 模型调用失败/输出不合法 —— 无法继续,直接报错(无兜底)。
|
||||
raise ReportCollaborationValidationError("PLANNER_UNAVAILABLE", "规划师智能体本轮未返回有效决策,请重试")
|
||||
await self._apply(session_id, record, state, decision, user_message=user_message, idempotency_key=idempotency_key)
|
||||
return decision
|
||||
|
||||
async def _agent_decision(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
history: list[dict[str, str]],
|
||||
state: dict[str, Any],
|
||||
previous_revision: int,
|
||||
max_rounds: int,
|
||||
) -> PrePlanDecision | None:
|
||||
llm = self._planner_llm()
|
||||
from app.report_collaboration.planning.planner_agent import PlannerAgent
|
||||
|
||||
agent = PlannerAgent(llm)
|
||||
turn = await agent.converse(
|
||||
user_text=text,
|
||||
history=history,
|
||||
previous_snapshot=state["snapshot"],
|
||||
previous_revision=previous_revision,
|
||||
clarification_round=int(state["clarification_round"] or 0),
|
||||
max_rounds=max_rounds,
|
||||
)
|
||||
if turn is None:
|
||||
return None
|
||||
requirement = agent.merge_requirement(
|
||||
turn,
|
||||
previous_snapshot=state["snapshot"],
|
||||
previous_revision=previous_revision,
|
||||
)
|
||||
return PrePlanDecision(
|
||||
action="ready" if turn.action == "ready" else "clarify",
|
||||
requirement=requirement,
|
||||
questions=[item for item in turn.questions if item and item.strip()][:3],
|
||||
impact=[item for item in turn.impact if item][:3],
|
||||
assumptions=[item for item in turn.assumptions if item],
|
||||
used_defaults=bool(turn.assumptions),
|
||||
reason="planner_agent",
|
||||
confidence=0.9,
|
||||
reply=turn.reply.strip(),
|
||||
)
|
||||
|
||||
async def _apply(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -146,7 +216,7 @@ class RequirementResolver:
|
||||
created_at=iso_now(),
|
||||
)
|
||||
clarifications.append(clarification.model_dump())
|
||||
content = format_clarification_message(decision.questions)
|
||||
content = decision.reply or ""
|
||||
metadata = {
|
||||
"preplan_for": user_message["message_id"],
|
||||
"clarification": clarification.model_dump(),
|
||||
@@ -155,17 +225,22 @@ class RequirementResolver:
|
||||
else:
|
||||
for item in clarifications:
|
||||
item["resolved"] = True
|
||||
content = format_ready_message(decision)
|
||||
metadata = {"preplan_for": user_message["message_id"], "ready_to_plan": True}
|
||||
content = decision.reply or ""
|
||||
metadata = {
|
||||
"preplan_for": user_message["message_id"],
|
||||
"ready_to_plan": True,
|
||||
# 结构化需求快照:前端据此渲染「需求摘要卡」(计划模式 UI)。
|
||||
"requirement": decision.requirement.model_dump(),
|
||||
}
|
||||
ready = True
|
||||
|
||||
payload = _dump_state(
|
||||
snapshot=decision.requirement,
|
||||
clarifications=clarifications,
|
||||
assumptions=list(decision.assumptions),
|
||||
clarification_round=round_count,
|
||||
ready_to_plan=ready,
|
||||
)
|
||||
payload = {
|
||||
"snapshot": decision.requirement.model_dump(),
|
||||
"clarifications": clarifications,
|
||||
"assumptions": list(decision.assumptions),
|
||||
"clarification_round": round_count,
|
||||
"ready_to_plan": ready,
|
||||
}
|
||||
await self._store.save_requirement_state(
|
||||
session_id,
|
||||
requirement_json=payload,
|
||||
|
||||
@@ -33,6 +33,16 @@ concurrency:
|
||||
fail_open: true
|
||||
|
||||
models:
|
||||
- name: MiniMax-M2.7
|
||||
display_name: MiniMax-M2.7
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: MiniMax-M2.7
|
||||
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
|
||||
base_url: https://api.minimaxi.com/v1
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: MiniMax-M2.7-fash
|
||||
display_name: MiniMax-M2.7-fash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
@@ -446,7 +456,10 @@ auth_login:
|
||||
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
||||
|
||||
database:
|
||||
backend: sqlite
|
||||
# 本地业务数据使用远程 MySQL;连接地址由 backend/.env.local 提供。
|
||||
# LangGraph 对话检查点仍由下方 checkpointer 保存到本机 SQLite。
|
||||
backend: mysql
|
||||
mysql_url: $MYSQL_DATABASE_URL
|
||||
sqlite_dir: .deer-flow/data
|
||||
# 高斯/OpenGauss 业务库示例(启用前先执行 uv sync --extra gauss):
|
||||
# backend: gauss
|
||||
@@ -738,6 +751,7 @@ workflows:
|
||||
report_collaboration:
|
||||
enabled: true
|
||||
worker_enabled: true
|
||||
planner_llm_enabled: true
|
||||
planner_model: null
|
||||
coordinator_model: null
|
||||
research_model: null
|
||||
|
||||
+4
@@ -18,6 +18,10 @@ class ReportCollaborationConfig(BaseModel):
|
||||
default=False,
|
||||
description="When true, a dispatcher/worker may claim run leases. Gateway-only processes keep this false.",
|
||||
)
|
||||
planner_llm_enabled: bool = Field(
|
||||
default=False,
|
||||
description="P1: LLM planner conversation (clarify + custom strategy). Off keeps the deterministic rule router and template plans.",
|
||||
)
|
||||
planner_model: str | None = Field(default=None, description="Model name from AppConfig.models for planning")
|
||||
coordinator_model: str | None = Field(default=None, description="Model name from AppConfig.models for the constrained coordinator")
|
||||
research_model: str | None = Field(default=None, description="Model name from AppConfig.models for research members")
|
||||
|
||||
@@ -538,6 +538,7 @@ class SystemSettings(BaseModel):
|
||||
default="v1",
|
||||
description="v1 opens the ordinary WeKnora experience; v2 opens the DeerFlow assistant knowledge base.",
|
||||
)
|
||||
ordinary_knowledge_landing: Literal["wiki", "weknora"] = "wiki"
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
|
||||
+2
@@ -241,6 +241,8 @@ class AssistantKnowledgeRepository:
|
||||
) -> tuple[str, str]:
|
||||
metadata = package.get("source") or {}
|
||||
provider = str(metadata.get("provider") or metadata.get("type") or "").strip().casefold()
|
||||
if provider == "file" and metadata.get("digest"):
|
||||
return f"file:{metadata['digest']}", str(metadata.get("filename") or fallback_name)
|
||||
if provider == "weknora":
|
||||
source_key = str(
|
||||
metadata.get("knowledge_base_id")
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
"""Merge assistant-wiki metadata and report-collaboration heads.
|
||||
|
||||
Revision ID: 20260909_01
|
||||
Revises: 20260903_01, 20260907_01
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
revision = "20260909_01"
|
||||
down_revision = ("20260903_01", "20260907_01")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -31,6 +31,8 @@ BUSINESS_TABLES = [
|
||||
"feedback",
|
||||
"agents",
|
||||
"skills",
|
||||
"ai_writing_article_types",
|
||||
"ai_writing_sessions",
|
||||
"scheduled_tasks",
|
||||
"scheduled_task_subscriptions",
|
||||
"scheduled_task_runs",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.gateway import assistant_file_ingest as ingest
|
||||
from app.gateway.routers import assistant_knowledge as router
|
||||
from deerflow.knowledge.schemas import NoteDraft
|
||||
from deerflow.persistence.assistant_knowledge import AssistantKnowledgeRepository
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
def test_same_topic_across_document_parts_preserves_all_knowledge():
|
||||
package = ingest.notes_package([
|
||||
{"title": "设备维护", "content_md": "每周清理过滤网", "tags": ["运维"]},
|
||||
{"title": "设备 维护", "content_md": "每月检查电机温度", "tags": ["设备"]},
|
||||
], {"digest": "document", "filename": "维护指南.md"})
|
||||
assert len(package["wiki_pages"]) == 1
|
||||
assert "每周清理过滤网" in package["wiki_pages"][0]["content"]
|
||||
assert "每月检查电机温度" in package["wiki_pages"][0]["content"]
|
||||
assert package["wiki_pages"][0]["tags"] == ["运维", "设备"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_upload_vectors_retry_and_dedup(tmp_path, monkeypatch):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
store = AssistantKnowledgeRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
base = await store.create_base(name="独立文档库", created_by="admin")
|
||||
app = FastAPI()
|
||||
app.include_router(router.router)
|
||||
app.state.assistant_knowledge_store = store
|
||||
app.state.llmwiki_embedding = None
|
||||
monkeypatch.setattr(ingest, "runtime_home", lambda: tmp_path)
|
||||
extraction_calls = []
|
||||
|
||||
async def actor(*args, **kwargs):
|
||||
return "admin"
|
||||
|
||||
async def extract(text, **kwargs):
|
||||
extraction_calls.append(text)
|
||||
return [NoteDraft(title="冷却系统", summary="摘要占位", content_md="# 冷却系统\n风扇的额定转速为每分钟 1200 转。", tags=["设备"], entities=[{"name": "风扇", "type": "product"}])]
|
||||
|
||||
class Encoder:
|
||||
fingerprint = "local-test-3"
|
||||
dimensions = 3
|
||||
config = SimpleNamespace(model="offline-test")
|
||||
|
||||
async def embed_texts(self, texts):
|
||||
return [[1.0, 0.0, 0.0] for _ in texts]
|
||||
|
||||
async def embed_query(self, text):
|
||||
return [1.0, 0.0, 0.0]
|
||||
|
||||
monkeypatch.setattr(router, "_actor", actor)
|
||||
monkeypatch.setattr(ingest, "extract_document_llm_multi", extract)
|
||||
|
||||
async def finish():
|
||||
if ingest._tasks:
|
||||
await asyncio.gather(*list(ingest._tasks.values()))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
payload = {"assistant_base_id": base["id"]}
|
||||
response = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("../../设备.md", "风扇额定转速为 1200 转。".encode())})
|
||||
assert response.status_code == 202, response.text
|
||||
job_id = response.json()["id"]
|
||||
await finish()
|
||||
assert (await store.get_job(job_id))["status"] == "failed"
|
||||
assert (ingest.file_job_dir(job_id) / "wiki.json").exists()
|
||||
app.state.llmwiki_embedding = Encoder()
|
||||
retry = await client.post(f"/api/assistant-knowledge/import-jobs/{job_id}/retry-file")
|
||||
assert retry.status_code == 202
|
||||
await finish()
|
||||
job = await store.get_job(job_id)
|
||||
assert job["status"] == "completed", job
|
||||
assert job["counts"]["vectors"] == 1
|
||||
assert len(extraction_calls) == 1
|
||||
results = await store.search_pages(base_ids=[base["id"]], query="额定转速", embedding_client=app.state.llmwiki_embedding)
|
||||
assert results and results[0]["retrieval_mode"] == "vector"
|
||||
assert "1200" in results[0]["matched_section"]["content"]
|
||||
assert "摘要占位" not in results[0]["matched_section"]["content"]
|
||||
global_base = await store.get_base()
|
||||
assert len(await store.list_pages(base_id=global_base["id"])) == 1
|
||||
again = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("设备.md", "风扇额定转速为 1200 转。".encode())})
|
||||
assert again.status_code == 202
|
||||
await finish()
|
||||
assert len(await store.list_sources(base_id=base["id"])) == 1
|
||||
assert len(await store.list_pages(base_id=global_base["id"])) == 1
|
||||
bad = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("script.py", b"print('no')")})
|
||||
assert bad.status_code == 415
|
||||
empty = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("empty.txt", b"")})
|
||||
assert empty.status_code == 422
|
||||
finally:
|
||||
await ingest.stop_file_jobs()
|
||||
await engine.dispose()
|
||||
@@ -25,6 +25,40 @@ class _DisabledCfg:
|
||||
report_collaboration = ReportCollaborationConfig(enabled=False)
|
||||
|
||||
|
||||
class _PlannerFakeLLM:
|
||||
"""规划师假模型:需求确认回「读者」追问;工作流设计回单份定制方案。"""
|
||||
|
||||
_converse = (
|
||||
'{"reply": "好的。先确认:这份报告的主要读者是谁?", "action": "clarify",'
|
||||
'"questions": ["这份报告的主要读者是谁?"], "impact": ["读者影响研究线划分"],'
|
||||
'"requirement": {"topic": "新能源汽车趋势报告"}}'
|
||||
)
|
||||
_ready = (
|
||||
'{"reply": "明白了,读者为企业战略决策人员。我来设计工作流。", "action": "ready",'
|
||||
'"assumptions": [], "requirement": {"topic": "新能源汽车趋势报告", "audience": "企业战略决策人员"}}'
|
||||
)
|
||||
_design = (
|
||||
'{"reply": "已按需求设计定制工作流。", "strategy": "custom", "title": "定制方案",'
|
||||
'"summary": "研究后写作", "rationale": "贴合需求", "estimated_duration_seconds": 900,'
|
||||
'"estimated_cost_level": "low", "quality_gate_ids": ["cite_required"],'
|
||||
'"nodes": ['
|
||||
'{"slot_id": "research-1", "role_key": "researcher", "display_name": "研究员", "mission": "检索证据", "depends_on": [], "output_artifact_type": "EvidenceBundle"},'
|
||||
'{"slot_id": "write-1", "role_key": "writer", "display_name": "撰写员", "mission": "成稿", "depends_on": ["research-1"], "output_artifact_type": "ReportSectionDraft"}]}'
|
||||
)
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
from types import SimpleNamespace
|
||||
|
||||
prompt = str(messages[-1][1]) if messages else ""
|
||||
if "请设计工作流" in prompt:
|
||||
payload = self._design
|
||||
elif "企业战略决策人员" in prompt or "读者" in prompt and "用户最新消息" in prompt:
|
||||
payload = self._ready
|
||||
else:
|
||||
payload = self._converse
|
||||
return SimpleNamespace(content=payload)
|
||||
|
||||
|
||||
def _client(monkeypatch: pytest.MonkeyPatch, *, enabled: bool = True, user: str | None = "user_a") -> tuple[TestClient, MemoryReportCollaborationStore]:
|
||||
monkeypatch.setattr(report_collaboration, "get_app_config", lambda: _Cfg() if enabled else _DisabledCfg())
|
||||
app = FastAPI()
|
||||
@@ -32,6 +66,7 @@ def _client(monkeypatch: pytest.MonkeyPatch, *, enabled: bool = True, user: str
|
||||
inner = MemoryReportCollaborationStore()
|
||||
store = PublishingReportCollaborationStore(inner, hub)
|
||||
app.state.report_collaboration_store = store
|
||||
app.state.report_collaboration_planner_llm = _PlannerFakeLLM
|
||||
app.state.report_collaboration_live_hub = hub
|
||||
app.state.report_collaboration_sse_settings = SseStreamSettings(
|
||||
deadline_seconds=0.6,
|
||||
@@ -126,14 +161,14 @@ def test_session_message_plan_run_http(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
proposed_body = proposed.json()
|
||||
assert proposed_body["session"]["status"] == "proposal_ready"
|
||||
plans = proposed_body["plans"]
|
||||
assert {item["strategy"] for item in plans} == {"balanced_review", "parallel_depth", "focused_fast"}
|
||||
assert {item["strategy"] for item in plans} == {"custom"}
|
||||
plan = next(item for item in plans if item.get("recommended"))
|
||||
assert plan["validation"]["ok"] is True
|
||||
assert all(node.get("output_artifact_type") and node.get("acceptance_criteria") for node in plan["nodes"])
|
||||
|
||||
listed = client.get(f"/api/report-collaboration/sessions/{session_id}/plans")
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()["plans"]) == 3
|
||||
assert len(listed.json()["plans"]) == 1
|
||||
|
||||
selected = client.post(
|
||||
f"/api/report-collaboration/sessions/{session_id}/plans/{plan['id']}/select",
|
||||
|
||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from app.report_collaboration.agentscope_runtime.event_adapter import ConversationReplica, MemberStreamContext, SeqAllocator, make_emitter
|
||||
from app.report_collaboration.agentscope_runtime.model_adapter import DeerFlowChatModelAdapter, ModelAdapterError, record_attempt_usage, redact_secrets
|
||||
from app.report_collaboration.agentscope_runtime.model_adapter import DeerFlowChatModelAdapter, ModelAdapterError, estimate_tokens_for_text, record_attempt_usage, redact_secrets
|
||||
from app.report_collaboration.agentscope_runtime.model_resolver import freeze_plan_models, resolve_role_model
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
@@ -128,6 +128,33 @@ async def test_adapter_streams_text_tool_and_usage() -> None:
|
||||
assert frozen.value.code == "MODEL_FROZEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_count_tokens_and_context_size_contract() -> None:
|
||||
"""AgentScope compress_context 依赖 model.context_size 与 count_tokens(messages, tools)。"""
|
||||
|
||||
snapshot = resolve_role_model("researcher", app_config=_app())
|
||||
adapter = DeerFlowChatModelAdapter(snapshot, app_config=_app())
|
||||
assert isinstance(adapter.context_size, int) and adapter.context_size > 0
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.content = text
|
||||
|
||||
def get_content_blocks(self):
|
||||
return [{"type": "text", "text": self.content}]
|
||||
|
||||
agentscope_style = [_Msg("市场分析" * 20)]
|
||||
tokens_msg = await adapter.count_tokens(agentscope_style, None)
|
||||
dict_style = [{"role": "user", "content": "hello world"}]
|
||||
tokens_dict = await adapter.count_tokens(dict_style, None)
|
||||
tools = [{"name": "web_search", "parameters": {"type": "object"}}]
|
||||
tokens_tools = await adapter.count_tokens(dict_style, tools)
|
||||
assert tokens_msg > 0
|
||||
assert tokens_dict == estimate_tokens_for_text("hello world")
|
||||
assert tokens_tools > tokens_dict
|
||||
assert await adapter.count_tokens([], None) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_redacts_provider_errors_and_records_metrics() -> None:
|
||||
snapshot = resolve_role_model("writer", app_config=_app())
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""P1-R 规划师智能体:真对话(文案由智能体产出直接落库)+ 工作流设计 + 降级路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.report_collaboration.contracts.requirements import ReportRequirementSnapshot
|
||||
from app.report_collaboration.planning.planner_agent import PlannerAgent
|
||||
from app.report_collaboration.requirement.resolver import RequirementResolver
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
|
||||
|
||||
|
||||
class _FakeLLM:
|
||||
def __init__(self, payload: str) -> None:
|
||||
self._payload = payload
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
return SimpleNamespace(content=self._payload)
|
||||
|
||||
|
||||
_TURN_CLARIFY = """```json
|
||||
{"reply": "好的,智能家居是个不错的方向。为了让团队分工更准,我想先确认两点:这份报告主要给谁看?大概需要多长的篇幅?",
|
||||
"action": "clarify",
|
||||
"questions": ["这份报告主要给谁看?", "大概需要多长的篇幅?"],
|
||||
"impact": ["读者决定研究线划分", "篇幅决定成员数量"],
|
||||
"requirement": {"topic": "智能家居设备2026年市场分析", "required_angles": ["市场规模", "竞争格局", "技术趋势"]}}
|
||||
```"""
|
||||
|
||||
_TURN_READY = """{"reply": "明白了:给公司管理层的战略规划参考,5000字左右,覆盖三个角度。我现在就可以为你们设计工作流了。",
|
||||
"action": "ready", "assumptions": ["地域默认中国大陆"],
|
||||
"requirement": {"topic": "智能家居设备2026年市场分析", "audience": "公司管理层", "required_angles": ["市场规模", "竞争格局", "技术趋势"], "length_target": "5000字"}}"""
|
||||
|
||||
_DESIGN_JSON = """{"reply": "我按三个角度设计了两条并行研究线汇写再审的链路,正好对齐管理层决策的阅读习惯。",
|
||||
"strategy": "custom", "title": "三角度并行研究工作流", "summary": "三线并行 + 汇写 + 独立审稿",
|
||||
"rationale": "需求明确三个角度,管理层需要可信结论", "estimated_duration_seconds": 1800,
|
||||
"estimated_cost_level": "medium", "quality_gate_ids": ["cite_required", "independent_review"],
|
||||
"nodes": [
|
||||
{"slot_id": "research-scale", "role_key": "researcher", "display_name": "研究员", "angle": "市场规模",
|
||||
"mission": "检索市场规模证据", "depends_on": [], "output_artifact_type": "EvidenceBundle", "suggested_tools": ["web_search"]},
|
||||
{"slot_id": "research-comp", "role_key": "researcher", "display_name": "研究员", "angle": "竞争格局",
|
||||
"mission": "检索竞争格局证据", "depends_on": [], "output_artifact_type": "EvidenceBundle"},
|
||||
{"slot_id": "research-tech", "role_key": "researcher", "display_name": "研究员", "angle": "技术趋势",
|
||||
"mission": "检索技术趋势证据", "depends_on": [], "output_artifact_type": "EvidenceBundle"},
|
||||
{"slot_id": "write-main", "role_key": "writer", "display_name": "撰写员",
|
||||
"mission": "汇总三线证据成稿", "depends_on": ["research-scale", "research-comp", "research-tech"], "output_artifact_type": "ReportSectionDraft"},
|
||||
{"slot_id": "review-1", "role_key": "reviewer", "display_name": "审稿员",
|
||||
"mission": "独立审稿", "depends_on": ["write-main"], "output_artifact_type": "ReviewDecision"}
|
||||
]}"""
|
||||
|
||||
|
||||
# ── converse:智能体对话 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_returns_agent_reply_and_questions() -> None:
|
||||
agent = PlannerAgent(_FakeLLM(_TURN_CLARIFY))
|
||||
turn = await agent.converse(
|
||||
user_text="帮我写一份智能家居设备2026年市场分析报告",
|
||||
history=[],
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_rounds=3,
|
||||
)
|
||||
assert turn is not None and turn.action == "clarify"
|
||||
assert "为了让团队分工更准" in turn.reply
|
||||
assert len(turn.questions) == 2
|
||||
merged = agent.merge_requirement(turn, previous_snapshot=None, previous_revision=0)
|
||||
assert merged.topic == "智能家居设备2026年市场分析"
|
||||
assert merged.revision == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_forced_ready_at_round_cap() -> None:
|
||||
agent = PlannerAgent(_FakeLLM(_TURN_CLARIFY))
|
||||
turn = await agent.converse(
|
||||
user_text="再说说",
|
||||
history=[],
|
||||
previous_snapshot={"topic": "智能家居市场", "revision": 2},
|
||||
previous_revision=2,
|
||||
clarification_round=3,
|
||||
max_rounds=3,
|
||||
)
|
||||
assert turn is not None and turn.action == "ready"
|
||||
assert turn.assumptions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_invalid_json_returns_none() -> None:
|
||||
agent = PlannerAgent(_FakeLLM("我拒绝输出 JSON"))
|
||||
assert (
|
||||
await agent.converse(
|
||||
user_text="写报告",
|
||||
history=[],
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_rounds=3,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_persists_agent_reply_without_template_wrapper() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="新会话", idempotency_key="ik1")
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: _FakeLLM(_TURN_CLARIFY))
|
||||
decision = await resolver.handle_user_message(
|
||||
session["id"],
|
||||
user_message={"message_id": "m1", "role": "human", "content": "帮我写一份智能家居设备2026年市场分析报告"},
|
||||
idempotency_key="ik-m1",
|
||||
)
|
||||
assert decision is not None and decision.reply and "为了让团队分工更准" in decision.reply
|
||||
messages = await store.list_messages(session["id"])
|
||||
ai_messages = [item for item in messages if item["role"] == "ai"]
|
||||
assert ai_messages, "agent reply message missing"
|
||||
assert "为了让团队分工更准" in ai_messages[-1]["content"]
|
||||
assert "收到您的报告需求" not in ai_messages[-1]["content"] # 模板文案不得出现
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_raises_when_agent_output_invalid() -> None:
|
||||
"""模型输出不合法 → 直接报错(无规则/模板兜底)。"""
|
||||
|
||||
from deerflow.persistence.report_collaboration import ReportCollaborationValidationError
|
||||
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="新会话", idempotency_key="ik1")
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: _FakeLLM("not json"))
|
||||
with pytest.raises(ReportCollaborationValidationError) as exc:
|
||||
await resolver.handle_user_message(
|
||||
session["id"],
|
||||
user_message={"message_id": "m1", "role": "human", "content": "帮我写一份智能家居设备2026年市场分析报告"},
|
||||
idempotency_key="ik-m1",
|
||||
)
|
||||
assert exc.value.code == "PLANNER_UNAVAILABLE"
|
||||
|
||||
|
||||
# ── design_workflow:工作流设计 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_workflow_returns_strategy_with_context_reply() -> None:
|
||||
requirement = ReportRequirementSnapshot(
|
||||
topic="智能家居设备2026年市场分析",
|
||||
required_angles=["市场规模", "竞争格局", "技术趋势"],
|
||||
revision=2,
|
||||
)
|
||||
history = [{"role": "human", "content": "给管理层看,覆盖三个角度"}]
|
||||
designed = await PlannerAgent(_FakeLLM(_DESIGN_JSON)).design_workflow(
|
||||
requirement=requirement, history=history
|
||||
)
|
||||
assert designed is not None
|
||||
strategy, reply = designed
|
||||
assert strategy.strategy == "custom"
|
||||
assert {slot.demand.role_key for slot in strategy.slots} == {"researcher", "writer", "reviewer"}
|
||||
assert "管理层" in reply or "设计" in reply
|
||||
write = next(slot for slot in strategy.slots if slot.slot_id == "write-main")
|
||||
assert set(write.depends_on) == {"research-scale", "research-comp", "research-tech"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_workflow_rejects_bad_vocabulary_and_cycles() -> None:
|
||||
requirement = ReportRequirementSnapshot(topic="测试", revision=1)
|
||||
agent = PlannerAgent(_FakeLLM(_DESIGN_JSON))
|
||||
|
||||
bad_role = _DESIGN_JSON.replace('"role_key": "writer"', '"role_key": "project_manager"')
|
||||
assert await PlannerAgent(_FakeLLM(bad_role)).design_workflow(requirement=requirement, history=[]) is None
|
||||
|
||||
cyclic = _DESIGN_JSON.replace(
|
||||
'"depends_on": [], "output_artifact_type": "EvidenceBundle", "suggested_tools"',
|
||||
'"depends_on": ["review-1"], "output_artifact_type": "EvidenceBundle", "suggested_tools"',
|
||||
)
|
||||
assert await PlannerAgent(_FakeLLM(cyclic)).design_workflow(requirement=requirement, history=[]) is None
|
||||
|
||||
bad_artifact = _DESIGN_JSON.replace('"output_artifact_type": "ReportSectionDraft"', '"output_artifact_type": "PDF"')
|
||||
assert await PlannerAgent(_FakeLLM(bad_artifact)).design_workflow(requirement=requirement, history=[]) is None
|
||||
_ = agent # 保持引用清晰
|
||||
@@ -12,7 +12,7 @@ from app.report_collaboration.planning.selector import AgentSelector
|
||||
from app.report_collaboration.planning.service import PlanProposalService
|
||||
from app.report_collaboration.planning.validator import validate_assembled_plan
|
||||
from deerflow.config.report_collaboration_config import ReportCollaborationConfig
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore, ReportCollaborationValidationError
|
||||
|
||||
|
||||
class _AgentStore:
|
||||
@@ -135,6 +135,26 @@ def test_validator_rejects_unknown_agent_id() -> None:
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
class _DesignFakeLLM:
|
||||
"""规划师工作流设计假模型:返回单份合法定制方案。"""
|
||||
|
||||
_payload = (
|
||||
'{"reply": "我按需求设计了两条研究线汇写再审的定制工作流。",'
|
||||
'"strategy": "custom", "title": "定制方案", "summary": "两线并行汇写再审",'
|
||||
'"rationale": "贴合需求", "estimated_duration_seconds": 1200,'
|
||||
'"estimated_cost_level": "medium", "quality_gate_ids": ["cite_required"],'
|
||||
'"nodes": ['
|
||||
'{"slot_id": "research-a", "role_key": "researcher", "display_name": "研究员", "angle": "市场", "mission": "检索市场证据", "depends_on": [], "output_artifact_type": "EvidenceBundle"},'
|
||||
'{"slot_id": "write-main", "role_key": "writer", "display_name": "撰写员", "mission": "成稿", "depends_on": ["research-a"], "output_artifact_type": "ReportSectionDraft"}'
|
||||
']}'
|
||||
)
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(content=self._payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_writes_plans_without_starting_a_run() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
@@ -146,15 +166,20 @@ async def test_service_writes_plans_without_starting_a_run() -> None:
|
||||
status="clarifying",
|
||||
)
|
||||
command = await store.enqueue_plan_request(session["id"], idempotency_key="ik-plan", expected_revision=1)
|
||||
saved = await PlanProposalService(store).fulfill(session["id"], user_id="u1", command_id=command["command_id"], idempotency_key="ik-plan")
|
||||
assert len(saved) == 3
|
||||
saved = await PlanProposalService(store, llm_factory=lambda: _DesignFakeLLM()).fulfill(
|
||||
session["id"], user_id="u1", command_id=command["command_id"], idempotency_key="ik-plan"
|
||||
)
|
||||
assert len(saved) == 1 # 智能体生成单份定制方案
|
||||
snapshot = await store.get_snapshot(session["id"])
|
||||
assert snapshot["session"]["status"] == "proposal_ready"
|
||||
assert snapshot["session"]["selected_plan_id"] is None
|
||||
assert snapshot["run"] is None
|
||||
assert {item["strategy"] for item in snapshot["plans"]} == {"balanced_review", "parallel_depth", "focused_fast"}
|
||||
assert snapshot["plans"][0]["strategy"] == "custom"
|
||||
assert all(item["requirement_revision"] == 1 for item in snapshot["plans"])
|
||||
assert snapshot["session"]["active_run_id"] is None
|
||||
ai = [item for item in snapshot["messages"] if item["role"] == "ai"][-1]
|
||||
assert ai["metadata"]["plan_source"] == "planner_agent"
|
||||
assert "定制工作流" in ai["content"]
|
||||
|
||||
selected = await store.select_plan(session["id"], saved[0]["id"], idempotency_key="ik-select", expected_revision=1)
|
||||
after_select = await store.get_snapshot(session["id"])
|
||||
@@ -163,6 +188,24 @@ async def test_service_writes_plans_without_starting_a_run() -> None:
|
||||
assert after_select["session"]["active_run_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_raises_when_planner_unavailable() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s1b")
|
||||
await store.save_requirement_state(
|
||||
session["id"],
|
||||
requirement_json={"snapshot": _requirement().model_dump(), "clarifications": [], "assumptions": [], "clarification_round": 1, "ready_to_plan": True},
|
||||
requirement_revision=1,
|
||||
status="clarifying",
|
||||
)
|
||||
command = await store.enqueue_plan_request(session["id"], idempotency_key="ik-plan2", expected_revision=1)
|
||||
with pytest.raises(ReportCollaborationValidationError) as exc:
|
||||
await PlanProposalService(store, llm_factory=lambda: None).fulfill(
|
||||
session["id"], user_id="u1", command_id=command["command_id"], idempotency_key="ik-plan2"
|
||||
)
|
||||
assert exc.value.code == "PLANNER_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_cycle_is_rejected() -> None:
|
||||
cfg = ReportCollaborationConfig()
|
||||
candidate_nodes_cycle = AssembledPlan.model_validate(
|
||||
|
||||
@@ -1,76 +1,35 @@
|
||||
"""RC-BE-005: pre-plan intent router and requirement resolver."""
|
||||
"""P1-R 需求确认:规划师智能体是唯一路径(无规则/模板兜底)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.report_collaboration.requirement.extractor import extract_topic, wants_direct_start
|
||||
from app.report_collaboration.requirement.intent_router import route_pre_plan_turn
|
||||
from app.report_collaboration.requirement.resolver import RequirementResolver
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore
|
||||
from deerflow.persistence.report_collaboration import MemoryReportCollaborationStore, ReportCollaborationValidationError
|
||||
|
||||
|
||||
def test_topic_only_asks_plan_affecting_gaps() -> None:
|
||||
decision = route_pre_plan_turn(
|
||||
"写一份新能源汽车主要品牌市场趋势分析报告",
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_clarification_rounds=3,
|
||||
)
|
||||
assert decision.action == "clarify"
|
||||
assert "新能源" in decision.requirement.topic
|
||||
assert decision.requirement.revision == 1
|
||||
assert len(decision.questions) >= 2
|
||||
assert any("读者" in item for item in decision.questions)
|
||||
assert any("角度" in item for item in decision.questions)
|
||||
class _ScriptedLLM:
|
||||
"""按顺序吐出预置 JSON 决策(模拟规划师智能体多轮)。"""
|
||||
|
||||
def __init__(self, payloads: list[str]) -> None:
|
||||
self._payloads = list(payloads)
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
payload = self._payloads.pop(0) if self._payloads else self._payloads[0]
|
||||
return SimpleNamespace(content=payload)
|
||||
|
||||
|
||||
def test_direct_start_records_default_assumptions() -> None:
|
||||
first = route_pre_plan_turn(
|
||||
"撰写新能源汽车趋势报告,直接开始",
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_clarification_rounds=3,
|
||||
)
|
||||
assert first.action == "ready"
|
||||
assert first.used_defaults is True
|
||||
assert first.requirement.audience == "企业战略决策人员"
|
||||
assert "市场份额" in first.requirement.required_angles
|
||||
assert first.assumptions
|
||||
_CLARIFY = """{"reply": "新能源汽车趋势是个好题目。先确认两个关键点:这份报告主要给谁看?希望覆盖哪些角度?",
|
||||
"action": "clarify", "questions": ["这份报告主要给谁看?", "希望覆盖哪些角度?"],
|
||||
"impact": ["读者影响研究线", "角度影响成员分工"],
|
||||
"requirement": {"topic": "新能源汽车趋势报告"}}"""
|
||||
|
||||
|
||||
def test_direct_start_without_topic_still_asks_topic() -> None:
|
||||
decision = route_pre_plan_turn(
|
||||
"直接开始",
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_clarification_rounds=3,
|
||||
)
|
||||
assert decision.action == "clarify"
|
||||
assert any("主题" in item for item in decision.questions)
|
||||
|
||||
|
||||
def test_clarification_cap_uses_explicit_assumptions() -> None:
|
||||
first = route_pre_plan_turn(
|
||||
"写一份行业报告",
|
||||
previous_snapshot=None,
|
||||
previous_revision=0,
|
||||
clarification_round=0,
|
||||
max_clarification_rounds=1,
|
||||
)
|
||||
assert first.action == "clarify"
|
||||
capped = route_pre_plan_turn(
|
||||
"先这样吧",
|
||||
previous_snapshot=first.requirement.model_dump(),
|
||||
previous_revision=first.requirement.revision,
|
||||
clarification_round=1,
|
||||
max_clarification_rounds=1,
|
||||
)
|
||||
assert capped.action == "ready"
|
||||
assert capped.used_defaults is True
|
||||
_READY = """{"reply": "清楚了:给投资机构,覆盖市场份额、价格策略和政策环境。我来为你们设计工作流。",
|
||||
"action": "ready", "assumptions": ["时间范围默认近两年"],
|
||||
"requirement": {"topic": "新能源汽车趋势报告", "audience": "投资机构", "required_angles": ["市场份额", "价格策略", "政策环境"]}}"""
|
||||
|
||||
|
||||
def test_extract_helpers() -> None:
|
||||
@@ -79,14 +38,15 @@ def test_extract_helpers() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_writes_clarification_without_planning() -> None:
|
||||
async def test_resolver_clarify_round_persists_agent_reply_and_record() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s1")
|
||||
user = await store.create_message(session["id"], role="human", content="写一份新能源汽车趋势报告", idempotency_key="m1")
|
||||
resolver = RequirementResolver(store)
|
||||
llm = _ScriptedLLM([_CLARIFY])
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: llm)
|
||||
decision = await resolver.handle_user_message(session["id"], user_message=user, idempotency_key="m1", max_clarification_rounds=3)
|
||||
assert decision is not None
|
||||
assert decision.action == "clarify"
|
||||
assert decision is not None and decision.action == "clarify"
|
||||
assert "先确认两个关键点" in (decision.reply or "")
|
||||
|
||||
snapshot = await store.get_snapshot(session["id"])
|
||||
assert snapshot["session"]["status"] == "clarifying"
|
||||
@@ -96,10 +56,14 @@ async def test_resolver_writes_clarification_without_planning() -> None:
|
||||
assert len(ai) == 1
|
||||
assert ai[0]["completed_at"]
|
||||
assert ai[0]["name"] == "协调者"
|
||||
# 智能体文案直接落库,模板开场不得出现
|
||||
assert "先确认两个关键点" in ai[0]["content"]
|
||||
assert "收到您的报告需求" not in ai[0]["content"]
|
||||
record = await store.get_session_record(session["id"])
|
||||
assert record["requirement_json"]["ready_to_plan"] is False
|
||||
assert record["requirement_json"]["clarifications"][0]["resolved"] is False
|
||||
|
||||
# 幂等:同一消息重放不产生第二条 AI 回复
|
||||
replay = await resolver.handle_user_message(session["id"], user_message=user, idempotency_key="m1", max_clarification_rounds=3)
|
||||
assert replay is None
|
||||
snapshot2 = await store.get_snapshot(session["id"])
|
||||
@@ -107,52 +71,63 @@ async def test_resolver_writes_clarification_without_planning() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_ready_is_not_plan_ready() -> None:
|
||||
async def test_resolver_ready_writes_requirement_metadata_for_summary_card() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s2")
|
||||
user = await store.create_message(
|
||||
session["id"],
|
||||
role="human",
|
||||
content="写一份新能源汽车趋势报告,面向战略决策层,必须覆盖市场、政策、海外和风险,直接开始",
|
||||
idempotency_key="m2",
|
||||
session["id"], role="human", content="写一份新能源汽车趋势报告,给投资机构看", idempotency_key="m2"
|
||||
)
|
||||
resolver = RequirementResolver(store)
|
||||
llm = _ScriptedLLM([_READY])
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: llm)
|
||||
decision = await resolver.handle_user_message(session["id"], user_message=user, idempotency_key="m2")
|
||||
assert decision is not None
|
||||
assert decision.action == "ready"
|
||||
assert decision is not None and decision.action == "ready"
|
||||
|
||||
snapshot = await store.get_snapshot(session["id"])
|
||||
assert snapshot["session"]["status"] == "clarifying"
|
||||
assert snapshot["plans"] == []
|
||||
assert snapshot["plans"] == [] # ready ≠ 已生成方案
|
||||
ai = [item for item in snapshot["messages"] if item["role"] == "ai"][-1]
|
||||
assert ai["metadata"]["ready_to_plan"] is True
|
||||
assert ai["metadata"]["requirement"]["audience"] == "投资机构"
|
||||
assert "设计工作流" in ai["content"]
|
||||
record = await store.get_session_record(session["id"])
|
||||
assert record["requirement_json"]["ready_to_plan"] is True
|
||||
assert all(item["resolved"] for item in record["requirement_json"]["clarifications"])
|
||||
assert "不等于已经生成" in snapshot["messages"][-1]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_merges_and_resolves_clarification() -> None:
|
||||
async def test_resolver_answer_merges_fields_across_rounds() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s3")
|
||||
first = await store.create_message(session["id"], role="human", content="写一份新能源汽车趋势报告", idempotency_key="m3a")
|
||||
resolver = RequirementResolver(store)
|
||||
llm = _ScriptedLLM([_CLARIFY, _READY])
|
||||
resolver = RequirementResolver(store, planner_llm_factory=lambda: llm)
|
||||
await resolver.handle_user_message(session["id"], user_message=first, idempotency_key="m3a")
|
||||
|
||||
second = await store.create_message(
|
||||
session["id"],
|
||||
role="human",
|
||||
content="读者是投资机构,必须覆盖市场份额、价格策略和政策环境",
|
||||
idempotency_key="m3b",
|
||||
session["id"], role="human", content="读者是投资机构,必须覆盖市场份额、价格策略和政策环境", idempotency_key="m3b"
|
||||
)
|
||||
decision = await resolver.handle_user_message(session["id"], user_message=second, idempotency_key="m3b")
|
||||
assert decision is not None
|
||||
assert decision.action == "ready"
|
||||
assert decision.used_defaults is False
|
||||
assert decision.requirement.audience == "投资机构"
|
||||
assert decision is not None and decision.action == "ready"
|
||||
assert decision.requirement.revision == 2
|
||||
assert decision.requirement.audience == "投资机构"
|
||||
assert set(decision.requirement.required_angles) == {"市场份额", "价格策略", "政策环境"}
|
||||
record = await store.get_session_record(session["id"])
|
||||
assert record["requirement_revision"] == 2
|
||||
assert all(item["resolved"] for item in record["requirement_json"]["clarifications"])
|
||||
snapshot = await store.get_snapshot(session["id"])
|
||||
assert snapshot["plans"] == []
|
||||
assert snapshot["session"]["status"] == "clarifying"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolver_raises_planner_unavailable_without_fallback() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
session = await store.create_session(owner_id="u1", title="", idempotency_key="s4")
|
||||
user = await store.create_message(session["id"], role="human", content="写报告", idempotency_key="m4")
|
||||
|
||||
# 工厂显式禁用(模型不可用)→ 直接报错,无规则兜底
|
||||
disabled = RequirementResolver(store, planner_llm_factory=lambda: None)
|
||||
with pytest.raises(ReportCollaborationValidationError) as exc:
|
||||
await disabled.handle_user_message(session["id"], user_message=user, idempotency_key="m4")
|
||||
assert exc.value.code == "PLANNER_UNAVAILABLE"
|
||||
|
||||
# 模型输出不合法 → 同样报错
|
||||
broken = RequirementResolver(store, planner_llm_factory=lambda: _ScriptedLLM(["我说人话不行吗"]))
|
||||
with pytest.raises(ReportCollaborationValidationError) as exc2:
|
||||
await broken.handle_user_message(session["id"], user_message=user, idempotency_key="m4")
|
||||
assert exc2.value.code == "PLANNER_UNAVAILABLE"
|
||||
@@ -155,6 +155,89 @@ async def test_waves_researcher_before_writer_and_text_does_not_complete() -> No
|
||||
assert phases[f"{run_id}-research-attempt1"] != phases[f"{run_id}-write-attempt1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_rework_cascades_downstream_and_reruns_in_order() -> None:
|
||||
"""P0.3 指定重跑级联:重跑 research 后 write 被级联 superseded 并按依赖序重跑。"""
|
||||
|
||||
store = MemoryReportCollaborationStore()
|
||||
run_id = await _boot(store)
|
||||
kernel = ScriptedMemberKernel()
|
||||
kernel.enqueue("research", TaskResult(kind="artifact_draft", artifact_type="EvidenceBundle", artifact_draft=_evidence()))
|
||||
kernel.enqueue("write", TaskResult(kind="artifact_draft", artifact_type="ReportSectionDraft", artifact_draft=_draft()))
|
||||
executor = WaveExecutor(store, kernel)
|
||||
assert await executor.run_until_idle(run_id) == "completed"
|
||||
|
||||
ledger = TaskLedger(store)
|
||||
changed = await ledger.apply_command_rework(
|
||||
run_id,
|
||||
command_id="cmd-cascade-1",
|
||||
instruction="重新查,重点看价格战",
|
||||
node_ids=["research"],
|
||||
cascade_downstream=True,
|
||||
)
|
||||
assert set(changed) == {"research", "write"}
|
||||
|
||||
nodes = await store.list_node_runs(run_id)
|
||||
by_node: dict[str, list[dict]] = {}
|
||||
for item in nodes:
|
||||
by_node.setdefault(item["node_id"], []).append(item)
|
||||
research_attempts = {item["attempt"]: item["status"] for item in by_node["research"]}
|
||||
write_attempts = {item["attempt"]: item["status"] for item in by_node["write"]}
|
||||
assert research_attempts[1] == "superseded" and research_attempts[2] == "planned"
|
||||
assert write_attempts[1] == "superseded" and write_attempts[2] == "planned"
|
||||
events = [item for item in await store.list_events(run_id) if item["type"] == "node.status.changed"]
|
||||
cascade_events = [item for item in events if (item.get("data") or {}).get("reason") == "downstream_cascade"]
|
||||
assert cascade_events and cascade_events[0]["data"]["node_id"] == "write"
|
||||
|
||||
kernel2 = ScriptedMemberKernel()
|
||||
kernel2.enqueue("research", TaskResult(kind="artifact_draft", artifact_type="EvidenceBundle", artifact_draft=_evidence("价格战证据")))
|
||||
kernel2.enqueue("write", TaskResult(kind="artifact_draft", artifact_type="ReportSectionDraft", artifact_draft=_draft()))
|
||||
executor2 = WaveExecutor(store, kernel2)
|
||||
assert await executor2.run_until_idle(run_id) == "completed"
|
||||
assert executor2.dispatched == ["research", "write"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exhausted_interrupted_node_fails_run_instead_of_zombie() -> None:
|
||||
"""P0.2 僵尸 run 守卫:attempt 耗尽的 interrupted 节点应让 run 进入 failed。"""
|
||||
|
||||
store = MemoryReportCollaborationStore()
|
||||
run_id = await _boot(store, _plan(max_attempts=2))
|
||||
# 模拟两次崩溃被 recovery 打断后 attempt 耗尽:research attempt2 interrupted。
|
||||
nodes = await store.list_node_runs(run_id)
|
||||
research = next(item for item in nodes if item["node_id"] == "research")
|
||||
research["attempt"] = 2
|
||||
research["status"] = "interrupted"
|
||||
research["error"] = "lease_reclaimed"
|
||||
await store.save_node_run(research)
|
||||
write = next(item for item in nodes if item["node_id"] == "write")
|
||||
write["status"] = "planned"
|
||||
|
||||
executor = WaveExecutor(store, ScriptedMemberKernel())
|
||||
status = await executor.run_until_idle(run_id)
|
||||
assert status == "failed"
|
||||
run = await store.get_run(run_id)
|
||||
assert run["status"] == "failed"
|
||||
events = [item for item in await store.list_events(run_id) if item["type"] == "run.status.changed"]
|
||||
assert events and events[-1]["data"]["reason"] == "node_attempts_exhausted"
|
||||
assert events[-1]["data"]["node_id"] == "research"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_with_budget_left_keeps_run_alive() -> None:
|
||||
"""未耗尽的 interrupted 节点仍留给 recovery 补开 attempt,不能提前 fail。"""
|
||||
|
||||
store = MemoryReportCollaborationStore()
|
||||
run_id = await _boot(store, _plan(max_attempts=3))
|
||||
nodes = await store.list_node_runs(run_id)
|
||||
research = next(item for item in nodes if item["node_id"] == "research")
|
||||
research["attempt"] = 1
|
||||
research["status"] = "interrupted"
|
||||
await store.save_node_run(research)
|
||||
ledger = TaskLedger(store)
|
||||
assert await ledger.refresh_run_terminal(run_id) != "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_researchers_share_phase() -> None:
|
||||
store = MemoryReportCollaborationStore()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Starts the SSH tunnel required by the local DeerFlow backend's remote MySQL connection.
|
||||
|
||||
.DESCRIPTION
|
||||
The local backend is configured to reach MySQL at 127.0.0.1:13307. This script forwards
|
||||
that port over SSH to the MySQL listener on the remote server. It deliberately does not
|
||||
store the SSH password; OpenSSH prompts for it when the tunnel starts.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RemoteHost = "119.254.86.251",
|
||||
[int]$SshPort = 22322,
|
||||
[string]$SshUser = "root",
|
||||
[int]$LocalPort = 13307,
|
||||
[int]$RemoteMySqlPort = 3307
|
||||
)
|
||||
|
||||
$existing = Get-NetTCPConnection -State Listen -LocalPort $LocalPort -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host "本地端口 $LocalPort 已在监听;SSH 隧道应已启动。" -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "正在建立 MySQL SSH 隧道:127.0.0.1:$LocalPort -> $RemoteHost`:$RemoteMySqlPort"
|
||||
Write-Host "请保持此窗口运行;按 Ctrl+C 可关闭隧道。"
|
||||
|
||||
ssh -N `
|
||||
-o ExitOnForwardFailure=yes `
|
||||
-o ServerAliveInterval=30 `
|
||||
-o ServerAliveCountMax=3 `
|
||||
-L "${LocalPort}:127.0.0.1:${RemoteMySqlPort}" `
|
||||
-p $SshPort `
|
||||
"${SshUser}@${RemoteHost}"
|
||||
Reference in new issue
Block a user