This commit is contained in:
xuhd committed 2026-06-01 10:49:53 +08:00
commit 22b93e32eb
493 files changed
+67159 -3160

No files matched your search

+7 -1
View File
@@ -16,7 +16,13 @@ VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+4 -1
View File
@@ -10,7 +10,10 @@ VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+5
View File
@@ -24,6 +24,11 @@
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
# VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
# VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
# 可选
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY=
+4 -1
View File
@@ -14,7 +14,10 @@ VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+65 -36
View File
@@ -12,8 +12,8 @@
- 三个圆桌 Router(`intent / recommend / multi_agent`)**通过 httpx ASGITransport 进程内直调本机 ASGI app**,复用既有 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream` 路由——**不走 TCP / 不经过网络栈**。所有 client 都由 `multi_agent._make_loopback_client(request)` 工厂统一构造。
- 历史包袱:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:<port>`,遇到开发机 `HTTP_PROXY` / `HTTPS_PROXY` 时仍可能把回环走代理(症状 `headers_in=42.160s`)。**改用 ASGITransport 后此问题不再存在**,相关 `trust_env=False` workaround 全部移除。
- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。
- **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**——任何环境都必须把 `roundtable-intent` + `roundtable-recommender` 两个**功能型 agent** 持久化进发布物,否则 `/api/intent/stream` 报 500。**互联网 demo 还需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。
- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体也是持久单例**(id 固定为 `roundtable-coordinator`,与 intent/recommender 同样走 `_roundtable_seed.ensure_roundtable_functional_agents()` 缺失自动落地)。2026-05 之前曾按 `roundtable-coordinator-{timestamp36}` 每会话新建并把动态席位列表写进 SOUL,导致 `.deer-flow/agents/` 累积大量壳目录;改造后 SOUL 是静态通用版本,「本次可调度席位列表」由 `multi_agent.init` 在 coordinator thread 创建后以 human 消息追加到对话历史,leader run 每轮都能看到。
- **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**。但功能型 agent 现在通过 `_roundtable_seed.ensure_roundtable_functional_agents()` **自动落地**(首次任何 intent/recommend/multi-agent 路由调用都会触发),因此**新部署不再需要手动准备** `roundtable-intent` / `roundtable-recommender` / `roundtable-coordinator` 三个目录——它们都是 seeder 自动写盘的。**互联网 demo 仍需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。
- **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。
- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。
- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。
@@ -282,33 +282,50 @@ offline-backend-20260512/backend/
请求体:
{
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
"model": "deepseek-chat" // 可选,写入 coordinator agent
"main_agent_name": "<可选,已废弃>", // 历史字段;2026-05 改造后后端忽略,统一用 COORDINATOR_AGENT_ID
"model": "deepseek-chat" // 可选;不传由 lead_agent 回退到 config.yaml models[0]
}
```
**协调智能体改造说明(2026-05)**:以前 `main_agent_name` 由前端用时间戳生成
(`roundtable-coordinator-${Date.now().toString(36)}`),后端 `_create_coordinator_direct`
把动态拼接的席位列表 SOUL 写盘并入 DB,导致 `.deer-flow/agents/` 越用越多。改造后:
- **id 固定**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"`(前后端常量必须保持一致);
- **缺失自动落地**:路由入口调 `ensure_roundtable_functional_agents()`,与 intent/recommender 共用同一套 seeder;
- **席位列表运行时注入**:init 创建好 coordinator thread 之后,立刻通过 `_append_thread_message` 把「【圆桌系统初始化】本次可调度席位:…」作为 human 消息追加到 thread 历史最前面,leader run 每轮都能看到;
- **`main_agent_name` 入参保留为向后兼容**:旧 client 仍可传,后端只是日志里看到不会用它创建新 agent。
**早期校验**(同步抛 HTTP 4xx,**不**进 SSE):
- `agent_names` 必须非空
- 每个 `agent_names` 元素 + `main_agent_name` 都需通过 `_AGENT_ID_PATTERN = ^[A-Za-z0-9_-]+$`(用 `_validate_agent_id` 提前 400;上游若收到不合规 id 会抛 422,提前拦更友好)
- 每个 `agent_names` 元素需通过 `_AGENT_ID_PATTERN = ^[A-Za-z0-9_-]+$`(用 `_validate_agent_id` 提前 400;上游若收到不合规 id 会抛 422,提前拦更友好)
- 所有 id 都 `.lower()` 后比较 / 存储
- `main_agent_name` 入参不再做校验(被忽略,统一用 `COORDINATOR_AGENT_ID` 常量)
**SSE 内部流程**:
**SSE 内部流程**(2026-05 单例改造后):
```
ensure_roundtable_functional_agents()
// 路由入口:保证 .deer-flow/agents/roundtable-coordinator/ 存在
// 缺失时由 _roundtable_seed.COORDINATOR_AGENT_SOUL 落地静态 SOUL
asyncio.as_completed(各席位 _prepare_sub_agent)
每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"}
(按真实完成顺序,先完成先发,前端因此能逐个点亮)
异常时:取消所有未完成的 task,避免泄露到 AsyncClient close 之后
协调智能体:
POST /api/threads → coord_thread
POST /api/agents
skills=["agent_orchestration"]
soul=动态拼接(本次 init 的席位列表 + 派活规则)
id=main_agent_name(前端生成的 roundtable-coordinator-<ts36>)
协调智能体(单例):
main_agent_name = COORDINATOR_AGENT_ID # 固定常量,不再每次新建 agent
coord_thread = _create_thread_direct(...) # 仍然每次新 thread,会话独立
agent_threads[main_agent_name] = coord_thread // ⚠️ thread_ids 含 coordinator 自身
# 通过 HTTP loopback append 一条 human 消息,把本次席位清单写入对话历史:
# "【圆桌系统初始化】本次研讨可调度的席位清单如下..."
# leader run 每轮都能从对话历史里读到,等效于旧版动态 SOUL 的作用。
_append_thread_message(seed_client, ..., coord_thread, seat_introduction)
→ data: {"event":"coordinator_ready","main_agent_name","thread_id"}
→ data: {"event":"init_done","main_agent_name","thread_ids":{...}}
@@ -316,28 +333,26 @@ asyncio.as_completed(各席位 _prepare_sub_agent)
失败 → data: {"event":"error","detail":"..."}
```
**协调智能体创建幂等**:若 `_create_agent` 返回 (False, "exists ...")(同名 agent 已存在)则继续往下走,不报错。注意这里的「已存在」会复用历史 agent 的 SOUL(与本次动态拼接的不同),通常只在前端用同一个时间戳跑过两遍时触发。
**协调智能体单例 SOUL**(磁盘上唯一一份,见 `_roundtable_seed.COORDINATOR_AGENT_SOUL`):
**协调智能体 SOUL**(动态拼接,与席位列表一起写入):
通用协调规则,**不含**席位列表。模型派活时从对话历史最前面的「【圆桌系统初始化】…」消息读取本次可用 agent_name。仍然强调英文 id(避免把中文显示名拼进 `agent_name`),靠后端 `_strip_display_suffix` 兜底(详见 §6.4)。
**席位清单注入消息形状**(init 阶段写入 coordinator thread):
```
#核心定位:
用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,
当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
#调度方法:
通过使用 agent_orchestration 技能调用其他智能体干活,使用技能时传入
agent_name 和需要做的活。
⚠️ agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),
不要把括号里的中文显示名拼进 agent_name 里。
#可调度智能体:
- agent_name: roundtable-intelligence(情报收集):<description>
- agent_name: roundtable-environment(环境评估):<description>
【圆桌系统初始化】本次研讨可调度的席位清单如下,请严格按 agent_name 通过 agent_orchestration 技能派活,不要派给不在清单内的 agent:
- agent_name: roundtable-intelligence(情报收集):<description>
- agent_name: roundtable-environment(环境评估):<description>
...
```
中文显示名通过 `GET /api/agents/{id}` 取 `name`、描述取 `description`。**强调英文 id** 是因为部分模型会把整段 `roundtable-x(中文)` 当 `agent_name` 塞回来,靠后端 `_strip_display_suffix` 兜底(详见 §6.4)。
中文显示名通过 `_get_agents_batch_direct` 一次 DB 查询取 `name`、描述取 `description`。
**单例 agent 的几个语义保障**:
1. **agent 落地幂等**:`_ensure_one` 检查目录是否存在,存在就 no-op,不覆盖运维手改的 SOUL;
2. **席位列表 append 失败不阻断**:`_append_thread_message` 包在 try/except 里,失败只记 warning,leader run 仍能跑(只是看不到席位清单,可能派活到错的 id,由 `_validate_agent_id` 在 run/stream 里报错);
3. **`_create_coordinator_direct` 已删除**:旧函数已移除(2026-05 改造),避免后续维护者误调;如果你正在 review 旧 PR 看到这个函数,那是 pre-rebase 版本。
### 6.2 `/run/stream` — `agent_type = "leader"`(`_leader_run`)
@@ -609,13 +624,20 @@ httpx.AsyncClient(
**已废止的 `trust_env=False` workaround**:旧版本走 `127.0.0.1:<port>` 真实 HTTP,需要 `trust_env=False` 阻止 httpx 读 `HTTP_PROXY` 把回环也走代理(症状日志 `headers_in=42.160s`)。改 ASGITransport 后这个问题在物理上已不存在——根本没走网络栈。
### 9.2 协调智能体每次新建
### 9.2 协调智能体单例(2026-05 改造)
`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录(实际仓库已有数十个 `roundtable-coordinator-mp*`),需后续 GC 策略:
历史实现 `main_agent_name = roundtable-coordinator-{timestamp36}`,会话隔离,但长期会累积 agent 目录(仓库里曾积累几十个 `roundtable-coordinator-mp*` 壳目录)。2026-05 改造选用上方决策 §9.2 选项 C 的本质:**把 coordinator 实例化为「内置 + 静态 SOUL + 运行时注入席位列表」**,固定 id `roundtable-coordinator`,与 `roundtable-intent` / `roundtable-recommender` 同样走 `ensure_roundtable_functional_agents()` 缺失自动落地。
- 选项 A:定时任务清理 N 天前无活跃 thread 的 coordinator agent
- 选项 B:每次创建前 LRU 清理(注意:跨服务实例难协调)
- 选项 C:把 coordinator 实例化为「内置 + 动态 SOUL」复用同一个 id
实现要点:
- **磁盘 SOUL 是通用版本**:不含本次席位列表,只规定协调规则、工具用法、终止条件;
- **席位列表运行时注入**:init 创建 coordinator thread 后立刻 `_append_thread_message` 写入一条 human 消息("【圆桌系统初始化】…"),位置在对话历史最前面,leader run 每轮自然读到;
- **`_create_coordinator_direct` 已删除**:不再有"每次创建"路径。
**遗留 `roundtable-coordinator-*` 目录的处理**:仓库可能仍有几十个历史时间戳壳目录。改造后**不会**再生成新的,但旧目录目前不自动清理(避免破坏可能仍存在的旧草稿引用)。运维可通过:
- 在 admin agents 管理页逐个 DELETE;
- 或写一次性脚本扫描 `.deer-flow/agents/roundtable-coordinator-*` 并删除目录 + DB 记录(注意不要删除 `roundtable-coordinator` 本体)。
### 9.3 澄清协议统一
@@ -705,15 +727,22 @@ offline-backend-20260512/backend/.deer-flow/*
| Agent | 角色 | 是否必须打包发布 |
|-------|------|------------------|
| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ **必须**(任何环境) |
| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ **必须**(任何环境) |
| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-coordinator` | **功能型单例**:Step 2 总控协调,2026-05 起为单例(id 固定) | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-intelligence` | 示例研讨席位(情报收集) | ⚠️ **互联网 demo 用**,内网不需要 |
| `roundtable-environment` | 示例研讨席位(环境评估) | ⚠️ 同上 |
| `roundtable-solution-design` | 示例研讨席位(方案设计) | ⚠️ 同上 |
| `roundtable-risk-review` | 示例研讨席位(风险审查) | ⚠️ 同上 |
| `roundtable-execution-plan` | 示例研讨席位(执行规划) | ⚠️ 同上 |
| `roundtable-summary` | 示例研讨席位(总结陈述) | ⚠️ 同上 |
| `roundtable-coordinator-*` | 协调智能体壳,每次会话动态新建 | ❌ **不打包**(运行时生成) |
| `roundtable-coordinator-*`(带后缀) | 2026-05 之前每次会话动态新建的临时壳 | ❌ **遗留**(仓库可能仍有几十个旧目录,新版本不再生成;运维按需清理) |
**关键变化(2026-05)**:三个功能型 agent(intent / recommender / coordinator)现在都由
``_roundtable_seed.ensure_roundtable_functional_agents()`` **自动落地**——任何 intent /
recommend / multi-agent 路由的入口都会触发,首次请求时缺什么补什么。所以**新部署的
机器 `git clone` 后既不需要手工拷目录、也不需要走下文的 `.gitignore` 白名单方案**,
SOUL 改动跟着 Python 代码走 git review 即可。
**关键区分**:
- `roundtable-intent` / `roundtable-recommender` 是**圆桌功能本身的一部分**——没有它们,整个 Step 1 + 推荐流程跑不起来,跟客户业务无关
@@ -16,6 +16,7 @@
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
- **协调智能体单例(2026-05)**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"` 是固定常量,不再每会话生成新 id。后端 seeder 自动落地,「本次席位列表」改由后端通过 thread 历史 human 消息注入,前端无感。`newCoordinatorName()` 仅作为兼容性壳保留。
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
@@ -341,7 +342,12 @@ React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。
**协调智能体命名(2026-05 改造)**:现在是**固定单例 id** `roundtable-coordinator`(见 `lib/roundtable-constants.ts::COORDINATOR_AGENT_ID`)。`newCoordinatorName()` 函数为兼容性保留,内部直接返回这个常量字符串。后端 `ensure_roundtable_functional_agents()` 缺失自动落地,不再每次新建。`filterRecommendCandidates` 在 `EXCLUDED_AGENT_IDS` 集合里同时排除:
- `roundtable-coordinator`(新版单例 id,永远过滤);
- `roundtable-coordinator-*` 前缀(历史残留,管理员清理之前先在 UI 隐藏)。
> 历史草稿兼容性:用户加载 2026-05 前保存的草稿时,存储的 `coordinatorName` 仍是 `roundtable-coordinator-<ts36>` 形式。只要那个旧 agent 目录还在磁盘上(即运维没手动删过),加载后继续在 Step 2 干活就能正常工作;如果旧 agent 被清理掉了,需要让用户「重新起草」从头跑一遍 Step 1 → Step 2。
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><g transform="translate(-128.5 -117)"><rect class="a" width="16" height="16" transform="translate(128.5 117)"/><path class="b" d="M4,0,8,5H0Z" transform="translate(140.5 128) rotate(180)"/></g></svg>

After

Width:  |  Height:  |  Size: 589 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="84.499" height="70" viewBox="0 0 84.499 70"><defs><style>.a{fill:url(#a);}</style><linearGradient id="a" x1="0.043" x2="0.978" y2="1.016" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0062d9" stop-opacity="0.6"/><stop offset="1" stop-color="#0062d9" stop-opacity="0.302"/></linearGradient></defs><path class="a" d="M8855.277-1940.39l-.054-70h78v31.183l6.5,5.257-6.5,5.259v28.3Z" transform="translate(-8855.224 2010.389)"/></svg>

After

Width:  |  Height:  |  Size: 536 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="77" height="45" viewBox="0 0 77 45"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 340 45"><image width="340" height="45" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAVQAAAAtCAYAAADxyJMIAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7tnd2O5MZ1x5vdPTO7WkmGhMiQDQS5NODbvEBeIgbygcRwjOQur+D1K+QiQC4CBMld8hJ+CgO+DpAIcixblvZjZnqG+f/PqcM6Vawii+zuda+0XLSaXawvFos//s85xVHX9/3mXFvXdeeq+htSb/9mB4iX+vlmfZu/PKLs1BX7/ET1fndznsn8w5X1Pkc5jvfPV5ZfPcu784zD6v683QWXMLJbknnpsHy7gXpmWP5sIYRaYHgqsC2dKG97/haQL4Xy2SH8Drqt024JI98BtXVUi/nOBM0pWLaAkX09JRx/sBDe+Vj99sjyR10jV/ijI5Xir44s78+jBcLMPwXis0H3HWz9pXoH1FPdgFLPCaF5DCjXAvLrALM/rQzKuWH36QnH7xTX9bMzw2IO2muhPAfgNwbeM4/fKa7xiet4B9RVAzq4ndb7GL3nquSrnFKXrcBcqhbngHlO4P3+Qp3oH54xcDAH7Dng5nO3FcBTwC3Blv5d29bPeNfbby5o3wF1EVBH/vtl0+tn0ti4TA2ex4JzDpD5uc8B81TQe3FhSnTRHEDmZycCwhys54Cb97sG4GNBW1O0R7sRTjSOS6/fGfO/A2pxcJsDnykcFZjlLYdmGZZj2JZUZisof4iufJHBaykUW+D38QJ1+fIth6ld3fcWwOCLTOUSyPm4zkHa4MvrVwNxK4BL4F0L3bO4DxaM7RnhuKbqd0BdEzuo+TdLSnNOZY6B2W3mgFlSki2grMFxCoinAOBtBbg3ATS14zajP3FT+/WJgPzE3bS/XnPruDJ2HsdUUwL0/6DC78tDsfyEb4Vw3q858K4B7tiN0CNI5rd4Dj+fHajC+S4zBmdbOFOGbxlQm5VnHO4SPH85HNar/Pno6kwrzTZgah05KKlO8rQSKEuQXArHGugIuBawEVot+fzw3U5E+W/w9Js67utZkrd0c7H8kq31XJmvBvClYC5BuATfHLylOWSq92P0L87v8gh44I6V7XjcctiWVG0K2caxvzzIfguA2nht8qkzDVLNXVef3eYHSYVUnbaNZ0GuOBWY5dmSw/M18lLF+C0Hp4GxdMPWoPnBCZRgK/xmwPXRswUuhSUQDHl/+2JF4KkE7KUQLvX1q4q5O3Xt8mM5aKl0nxTcDrWxyl0KU4o2wlVvtCnXQTtYV9y0lwHXbyhQF16PHJ7+Ca3qs361vMk+pTw9NKPCTOtNYanHcqU5pTJPBccZELYC7vFEQF3ByDdSZBsAaudpv+canwX4UjAvgbB1Lodui7K1sh64Ebbjm25ayVptWu67btRyBbs4+PWHg+s3AKiL4ZnOd+/3HJvuqao01VkDZ6o0O5jmaXn75cGpwNSMY1imM6MEzDklWYNaTfUh/3fQFcLh5EC8v5BF+3PEaz1+tdAt0Fjvl1NAranpOQiXoDvnYvDQ9cBNXQjpDViGLc885jPQ/koGpH4De0Wb+mM3y1/RfTOQfQuBugCgpah7C0BT1elvA70qn2brUBWcZbX5x6H4i2C2KjTHV/e22yb3WwRleSaUQFmA5HeOUYmNAOyfnsA90Aibty7bl7jY141R6yMAXYRwCb5LwVuDrsH2Wf8o1+S/8RkHyfRGmXch9JuP3JWtuQ2mAEswzwe7XCPnAexbBNRGkFq25xi7sXO9KwSQNhP+TsJzPPI+KGRqk+A0aD5DJ14EyHrV6RXmJw6qMXAT24og1LQMlgZKb2bOKsoaIClJC1t/dxpQ9nfjB0h3rcqkdEyeTuH4VJ5TwbWlran++vLH9KkKXkC5uM0AuDQ3RuDNoVsCbm1FxE2AKTsXAYslYWEaP4PflqDV+Rtv4AjYmOb9tPkqA1WyMhWGPe8isES6Cp6HH828bM7YdGkvHKiNEOWp5mrUYDq1bGnKhDcVOl6O1GENoQ1uBx+n7kdwlsx0TUvN81Z4Sr4mpTmjKE+pJGsgbJp1LlN/OG/AaWl/avm7/YrAVaGyU8GXVXevZpRvg+IdAZaK9Ius4zVV+1Wn6pRbVLLpTWugVZeBHist9zLIlgJg0UUw5R7Qfoz9rwumwPFwvVCgNoLUQ3QcSJI5N4xmhOd4gE2FlnyeCk8LEOV+Tk3PfZs1c11VZ644c2DmVzX+rgDzGFC2gHE19J4UFO5XGIEPFszxS83K87DNzud1o2mfnVMLrI8BcRW8a4DrFe3NJgJVz0lv3NRf2zvY2pn3ULS2vnYtZLW9qF7jqNYCXM0ugfVgvSCgNtw5BtDclC8Fk8wPGpcraQPe/xkBqsfSYBFTOihP65jBU9O5jddjhjwO5GqqT0EzB+bwO4By8dWdguQiOJaA2HCZzpWlb/TptrbfNQClta6j8xHQV21AngPwWviOwDs9Pn2ibhW0erNERRuVUVx/G/Ikil8By/JfqCdI6nkW0mxwP3THPstuizTQlV6O8XItPX4GwF42UOtm/Hj++nWfPgofAcpF8ikczXS3pUlT8PzAKVWrxfyc0b85D84IBZ0tGtCpq1J3rAbKWUieAYwnhdvNhUf/b08XzT8LxGeUcQ3AS8BbgG1ZnWIyB9BGmBpsU9fBWM3mboMcsgrYuNVWFKQBLl9C2zQFO145MOZKkjKvbS4XqK0wTU35eMbRB5oO0liFjpcrmQk/XpLU4U0dbonZnvk3/ah3m1RVdc48T/OlvewyeEbV2uJzPAFAVwHz0sE4c7uc/PAKEJ8EuA2uh2MhC8CmQL1KzP/EZwfAat7oLoAbYPSQ6gdXQe1FhXTpluiR4Zqpet1sch+swTVforUaqvH2L82XywNqbtaXzXk9F2/Opwvn47nWVaiOzG1gVWk9p19yFFRogOdYUdbBmV8BVaYa+U6g6pRmWbEeCcpmSP4BwDirsk9Ou7YK58zrtloW5KKZ/XVb/qPhu0LZOlWbBzrUhNNAWTxWAe2gYvX+rLsKzB9bh6yW9+o1Va5xMCNcx0GaHLAr3QGXA9Tn4bxjdD6dVbWgEpXo+N12vmGk5cevYarvkzB77ca1Dk/rh1a4AJwllRnAYcCM4FRYjtO19aqtMQvJM8BxNfyusyCV+Qzvj1ue1T9Muwy63elM9mRS3rX5PHM8ngXSM2p4NXwr0MU5jEz+AmxjACIFbb9J/bOSb/DJpko2AteWb/FvIUwBtuR7bfW5rlKu8fa8HKD+WT7t8JsQrQeVIkjjMiYFafSFxgi8+kB1K8NzDC7n74Spnh8f4Adw+mOqQKNpHiEZFWbN3E+A6mA5BuqJQNkExxyEhUuVJ81BrqGKtzrLIog3gPlkEJ4A70Lo4g/fDA+UCM4UtHrT6Jpirwptv098swrZJF8Bsj7oFVcU3AzFbAVBBLGpV3ML8IgCNrZF9Tq1YsD7WyfU65LFdef9f0oZUPn9v+FeSk16TfSRefpDoxKNNyDNeKrQX0O5GEjp+/yYMO0677MeFsN75akL3eHHTCE6Y6ZzOVAEYqq6cuU5AmqAZ0x/H3VlAaylhKnC8pmrqUEdrobjIVOOT9HuHpM4T6+dWK48qTRn1OjG1OhcvrzNNSr2VVYJz23F1gRfvwLgRbmRo6FbcDdMQLYESK5UMOUKn+jQz971LcLUvbxBuA0vNnyZuAwkv3tJoRcs2HrZG2mD/le757lMKw6QD2ylbgHNk75QoGnfw+cX2RinS7H0YAGslwPUf3AnYCAtReipRhWiCh9To+YLZZpF5DWApBsGs7A4ntCSrRIsGvk6R8ozNdVjvzL/qLRxv/Gvl6aQVcU5p1yzq5wo4dGxTYOyXA1L35oHJKGZb0vhFsr3rt4uwMqnFVratObLy1q5Up3FtDkAe9iuBK1vtwm6E0p3NWzrirZ3sC2qT7uM8q1qNsI0fVECMI3H3HlHBVtzEUTrlIEu3fw6WINr+ncJbEkWX4uVW1PKmVugtkqgAaqXA9QfuWFUv2g6jw2kClA9GANKMa9F4fk08kC1MgGgUoea8VLfhxzV7BXJzN/JP4FWU5oJCAumuh5PoVmC51i5qutgbPLPwPJoUJYgOQHGOdClF3PBr+s39CbV3ZJbob3/k6A2KJ9I6cpEngN9BborgMu/TTsAMoyI3pyp6V6HbR2yg6vg91qx1uHfDIuAjfVz5YAB1q8ioHqV+89dY32xIILV2iBgvb81XSWg9UwEsJbMovOa/P84AVGeRFSi0Yy3eU1wKkBT8JThKdec//nA+T4BzyHdgTOmlRWnh11s+2ZCiWaBp2qQ6rovqVmdXUsV35yJfT0G9ipAtsDv0BiAemIX9w1/v25sb98QkFoB6SKA72ZcCQsV8CR024FbB6WCdgTbgqItAtmrWe8q+OrawVUvUw/I6je3q+w4FSsDXKZcrQwBG5WrpipcU5eNh2tJtRYCWP3zxvlDCC2JYLVXqzm7H4cScZlTHlyK78Kb8jSfiFei5nd06pM1B3hKUwGesg94WleL5nbm2/RKU8uH7le+hzxoMwWkgqXbvKen78arC8Csmf/joa0B81hQ1gC5EIr9Q6PKfFgIW6t3h5uG+/y2jWC8Cmk+H4+XoLnDzZS3z7SGrbN2j4XxAgAvAu8pgPsyDER8kKiKVNO9DMZxOouYsk3KzKpap2a9mgZk07YNsApX3Uyt5urVg5X54uuwXrmqWyBLine truncated

After

Width:  |  Height:  |  Size: 12 KiB

+182 -21
View File
@@ -1,24 +1,185 @@
.sidebar-contents{
width:98px;
height:986px;
background:rgba(3, 38, 80, 0.8);
border:1px solid;
border-color:rgba(43, 143, 254, 0.6);
border-radius:4px;
backdrop-filter:blur(2px);
.sidebar-contents {
width: 98px;
min-height: 986px;
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 10px;
position: relative;
}
.item-nav{
width:78px;
height:70px;
background:rgba(0, 12, 28, 0.15);
border:1px solid;
border-color:rgba(85, 166, 254, 0.2);
line-height:8px;
font-family:Noto Sans S Chinese;
font-weight:500;
color:rgba(255, 255, 255, 0.8);
font-size:20px;
padding-bottom:18px;
text-align:center;
.item-nav {
width: 100%;
height: 70px;
background: #ffffff1a;
border-color: #fefafa4d;
margin-top: 13px;
font-family: Noto Sans S Chinese;
font-weight: 550;
color: #fff;
font-size: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
background: #000c1c26;
border: 1px solid;
border-color: #55a6fe33;
box-shadow: 0 0 20px #00b9ff26 inset;
position: relative;
}
.item-nav:hover {
background: rgba(85, 166, 254, 0.12);
color: #ffffff;
}
.item-nav__label {
display: block;
width: 3em;
margin: 0 auto;
padding-left: 6px;
box-sizing: border-box;
letter-spacing: 6px;
word-break: break-all;
}
.item-nav-content {
width: 155px;
min-height: 970px;
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
position: absolute;
left: 97px;
top: 0;
z-index: 1000;
padding: 14px 13px 18px 16px;
overflow-y: auto;
}
.item-nav-active {
color: #ffffff;
background: linear-gradient(134.25deg,
rgba(0, 98, 217, 0.59) 2.28%,
rgba(0, 98, 217, 0.3) 99.59%) !important;
background-size: cover;
border: 1px solid;
border-color: rgba(255, 255, 255, 0.2);
position: relative;
}
.item-nav-active::before {
content: '';
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 10px solid rgba(0, 98, 217, 0.45);
pointer-events: none;
}
.item-nav-active::after {
content: '';
position: absolute;
left: 0;
top: 24px;
width: 77px;
height: 45px;
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
background-size: cover;
}
.sidebar-flyout-list {
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
}
.sidebar-flyout-list--level-1,
.sidebar-flyout-list--level-2,
.sidebar-flyout-list--level-3 {
margin-left: 19px;
padding-left: 14px;
}
.sidebar-flyout-node {
position: relative;
}
.sidebar-flyout-node--has-children.sidebar-flyout-node--expanded>.sidebar-flyout-list::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 100%;
background: rgba(43, 143, 254, 0.22);
}
.sidebar-flyout-item {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
gap: 9px;
border: 0;
background: transparent;
padding: 3px 0;
font-family: Noto Sans S Chinese;
font-size: 18px;
line-height: 28px;
font-weight: 500;
letter-spacing: 0;
color: rgba(255, 255, 255, 0.8);
text-align: left;
cursor: pointer;
transition: color 0.2s ease, text-shadow 0.2s ease;
}
.sidebar-flyout-item:hover,
.sidebar-flyout-item--active {
color: #48ddff;
text-shadow: 0 0 8px rgba(72, 221, 255, 0.25);
}
.sidebar-flyout-item--leaf {
justify-content: center;
padding-left: 0;
}
.sidebar-flyout-list--level-1>.sidebar-flyout-node>.sidebar-flyout-item--leaf,
.sidebar-flyout-list--level-2>.sidebar-flyout-node>.sidebar-flyout-item--leaf,
.sidebar-flyout-list--level-3>.sidebar-flyout-node>.sidebar-flyout-item--leaf {
padding-left: 0;
}
.sidebar-flyout-arrow {
width: 9px;
height: 9px;
flex: 0 0 9px;
background: url('../assets/icon/down-logo.svg') center / contain no-repeat;
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.sidebar-flyout-node--expanded>.sidebar-flyout-item>.sidebar-flyout-arrow {
transform: rotate(0deg);
}
.sidebar-flyout-label {
flex: 1;
white-space: nowrap;
}
+119 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
@@ -74,6 +74,7 @@ const menuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
@@ -132,9 +133,24 @@ const menuItems: MenuItem[] = [
label: "COH中任务",
icon: Folder,
children: [
{ id: "coh-task-3qfx", label: "3QFX", icon: ListTodo },
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
{
id: "coh-task-3qfx",
label: "3QFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment",
},
{
id: "coh-task-fx",
label: "任务FX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx",
},
{
id: "coh-task-xdfx",
label: "XDFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action",
},
],
},
{
@@ -222,9 +238,24 @@ const menuItems: MenuItem[] = [
label: "资源管理",
icon: Database,
children: [
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network },
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
{
id: "resource-network-xwt",
label: "网络xwt资源管理",
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
{
id: "resource-evaluation-index",
label: "作战资源管理",
icon: FileBox,
path: "/page/strategy/resource-management/resource-evaluation-index",
},
],
},
];
@@ -254,7 +285,10 @@ export function PageSidebarDark({
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin);
const visibleMenuItems = useMemo(
() => filterMenuItemsByRole(menuItems, !!isAdmin),
[isAdmin],
);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
@@ -275,6 +309,26 @@ export function PageSidebarDark({
setSettingsOpen(true);
};
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
const initialActiveTopItem = visibleMenuItems.find(hasActiveChild) ?? visibleMenuItems[0];
const [activeTopId, setActiveTopId] = useState(initialActiveTopItem?.id ?? "qa-center");
useEffect(() => {
const matchedTopItem = visibleMenuItems.find(hasActiveChild);
if (matchedTopItem) {
setActiveTopId(matchedTopItem.id);
}
}, [pathname, visibleMenuItems]);
const activeTopItem = visibleMenuItems.find((item) => item.id === activeTopId) ?? initialActiveTopItem;
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
@@ -287,6 +341,51 @@ export function PageSidebarDark({
});
};
const handleTopItemClick = (item: MenuItem) => {
setActiveTopId(item.id);
if (!item.children?.length && item.path) {
navigate(item.path);
}
};
const handleMenuItemClick = (item: MenuItem) => {
if (item.children?.length) {
toggleExpanded(item.id);
return;
}
if (item.path) {
navigate(item.path);
}
};
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
{items.map((item) => {
const hasChildren = !!item.children?.length;
const isExpanded = expandedIds.has(item.id);
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<div
key={item.id}
className={`sidebar-flyout-node sidebar-flyout-node--level-${level} ${hasChildren ? "sidebar-flyout-node--has-children" : ""} ${isExpanded ? "sidebar-flyout-node--expanded" : ""}`}
>
<button
type="button"
className={`sidebar-flyout-item ${isSelected ? "sidebar-flyout-item--active" : ""} ${hasChildren ? "sidebar-flyout-item--parent" : "sidebar-flyout-item--leaf"}`}
onClick={() => handleMenuItemClick(item)}
>
{hasChildren ? <span className="sidebar-flyout-arrow" aria-hidden="true" /> : null}
<span className="sidebar-flyout-label">{item.label}</span>
</button>
{hasChildren && isExpanded ? renderFlyoutMenu(item.children ?? [], level + 1) : null}
</div>
);
})}
</div>
);
return (
<>
<SettingsDialog
@@ -301,9 +400,19 @@ export function PageSidebarDark({
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
<div className="sidebar-contnet">
<div className="sidebar-contents">
<div className="item-nav">
问答管理
<div className="item-nav-content">
{activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null}
</div>
{visibleMenuItems.map((item) => (
<button
type="button"
key={item.id}
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""}`}
onClick={() => handleTopItemClick(item)}
>
<span className="item-nav__label">{item.label}</span>
</button>
))}
</div>
</div>
</>
+85 -14
View File
@@ -73,6 +73,7 @@ const menuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
// 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
// 需要重新开放时取消注释即可。
// {
@@ -130,8 +131,18 @@ const menuItems: MenuItem[] = [
label: "XK中任务",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
{
id: "xk-task-jc",
label: "任务JC",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-jc",
},
{
id: "xk-task-dispatch",
label: "任务调度",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-dispatch",
},
],
},
{
@@ -139,9 +150,24 @@ const menuItems: MenuItem[] = [
label: "COH中任务",
icon: Folder,
children: [
{ id: "coh-task-3qfx", label: "3QFX", icon: ListTodo },
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
{
id: "coh-task-3qfx",
label: "3QFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment",
},
{
id: "coh-task-fx",
label: "任务FX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx",
},
{
id: "coh-task-xdfx",
label: "XDFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action",
},
],
},
{
@@ -149,9 +175,24 @@ const menuItems: MenuItem[] = [
label: "PG中任务",
icon: Folder,
children: [
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
{
id: "pg-task-pg",
label: "任务PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-task-pg",
},
{
id: "pg-task-xdpg",
label: "XDPG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-action-pg",
},
{
id: "pg-task-info-pg",
label: "信息PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-info-pg",
},
],
},
{
@@ -159,9 +200,24 @@ const menuItems: MenuItem[] = [
label: "TY中任务",
icon: Folder,
children: [
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
{
id: "ty-task-xdty",
label: "XDTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-mechanism",
},
{
id: "ty-task-plan",
label: "方案TY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-plan",
},
{
id: "ty-task-xxty",
label: "XXTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-zsty",
},
],
},
],
@@ -236,9 +292,24 @@ const menuItems: MenuItem[] = [
label: "资源管理",
icon: Database,
children: [
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network },
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
{
id: "resource-network-xwt",
label: "网络xwt资源管理",
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
{
id: "resource-evaluation-index",
label: "作战资源管理",
icon: FileBox,
path: "/page/strategy/resource-management/resource-evaluation-index",
},
],
},
];
+3 -3
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-[4px] border py-6 shadow-sm dark:border-[#0049A5] dark:bg-[#02244C]",
className,
)}
{...props}
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-muted-foreground text-sm dark:text-[#F2F3F5]", className)}
{...props}
/>
);
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn("px-6 ", className)}
{...props}
/>
);
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input/50 dark:bg-background/80 relative flex w-full items-center rounded-md border bg-white/80 shadow-xs transition-[color,box-shadow] outline-none",
"group/input-group relative flex w-full items-center rounded-md border border-input/50 bg-white/80 shadow-xs transition-[color,box-shadow] outline-none dark:border-[#1B477E] dark:bg-[#022248]",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
+6 -3
View File
@@ -8,9 +8,12 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] md:text-sm",
"border-input placeholder:text-muted-foreground",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] rounded-[4px] text-[16px]",
"file:text-foreground selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
+60 -54
View File
@@ -30,7 +30,8 @@ const tabsListVariants = cva(
{
variants: {
variant: {
default: "bg-muted",
default:
"bg-muted dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-hidden dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
line: "gap-1 bg-transparent",
},
},
@@ -40,60 +41,65 @@ const tabsListVariants = cva(
},
);
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>
>(({ className, variant = "default", ...props }, ref) => {
return (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
});
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>
>(({ className, variant = "default", ...props }, ref) => {
return (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
});
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Trigger
ref={ref}
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Content
ref={ref}
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
});
TabsContent.displayName = TabsPrimitive.Content.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Trigger
ref={ref}
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:text-muted-foreground dark:hover:text-foreground",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground",
"dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-input dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-input/30 dark:group-data-[variant=line]/tabs-list:data-[state=active]:text-foreground",
"dark:group-data-[variant=default]/tabs-list:relative dark:group-data-[variant=default]/tabs-list:!h-full dark:group-data-[variant=default]/tabs-list:self-stretch dark:group-data-[variant=default]/tabs-list:flex-none dark:group-data-[variant=default]/tabs-list:rounded-none dark:group-data-[variant=default]/tabs-list:border-0 dark:group-data-[variant=default]/tabs-list:bg-transparent dark:group-data-[variant=default]/tabs-list:px-4 dark:group-data-[variant=default]/tabs-list:!py-0 dark:group-data-[variant=default]/tabs-list:!leading-none dark:group-data-[variant=default]/tabs-list:shadow-none dark:group-data-[variant=default]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=default]/tabs-list:[&_svg]:text-[#CCD2DA]",
"dark:group-data-[variant=default]/tabs-list:not(:first-child):border-l dark:group-data-[variant=default]/tabs-list:not(:first-child):border-[#1d3961] dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-l-0",
"dark:group-data-[variant=default]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=default]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=default]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:hover:text-[#CCD2DA]",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => {
return (
<TabsPrimitive.Content
ref={ref}
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
});
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+3 -1
View File
@@ -7,7 +7,9 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input placeholder:text-muted-foreground/60 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] ",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
@@ -267,15 +267,18 @@ export function AgentCard({ agent }: AgentCardProps) {
return (
<>
<Card className="group flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
<CardHeader className="min-w-0 pb-3">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base" title={desensitize(agent.name)}>
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
@@ -146,15 +146,21 @@ export function AgentGallery() {
const renderEmpty = (description: string, showNewButton = false) => (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-muted-foreground h-7 w-7" />
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
<BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
</div>
<div>
<p className="font-medium">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
{description}
</p>
</div>
{showNewButton && (
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
<Button
variant="outline"
className="mt-2 dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
onClick={handleNewAgent}
>
<PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent}
</Button>
@@ -68,7 +68,7 @@ export function TagSearchBar({
return (
<div className={cn("flex flex-col gap-2.5", className)}>
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
+30 -1
View File
@@ -9,11 +9,26 @@ export interface ChatMessage {
agentName?: string;
}
export interface toolsUser {
email: string;
id: string;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
pages_limit: number;
pages_used: string;
yUserId: string;
}
interface ChatContextType {
messages: ChatMessage[];
addMessage: (role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => void;
clearMessages: () => void;
deleteMessage: (id: string) => void;
startConfig: unknown;
setStartConfig: (val: unknown) => void;
toolsUser: toolsUser | null;
setToolsUser: (val: toolsUser) => void;
}
const ChatContext = createContext<ChatContextType | undefined>(undefined);
@@ -28,6 +43,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}
});
const [startConfig, setStartConfig] = useState<unknown>(null);
const [toolsUser, setToolsUser] = useState<toolsUser | null>(null);
const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
try {
localStorage.setItem('qa-chat-history', JSON.stringify(newMessages));
@@ -66,7 +84,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}, [saveToStorage]);
return (
<ChatContext.Provider value={{ messages, addMessage, clearMessages, deleteMessage }}>
<ChatContext.Provider
value={{
messages,
addMessage,
clearMessages,
deleteMessage,
startConfig,
setStartConfig,
toolsUser,
setToolsUser,
}}
>
{children}
</ChatContext.Provider>
);
+4 -4
View File
@@ -48,16 +48,16 @@ const themes: Record<ThemeType, ThemeConfig> = {
textSecondary: 'slate-400',
primary: 'blue-500',
primaryHover: 'blue-400',
border: 'slate-700',
accent: 'blue-600',
border: 'blue-800',
accent: 'cyan-400',
success: 'emerald-500',
warning: 'amber-500',
error: 'rose-500',
card: 'slate-800',
cardHover: 'slate-700',
input: 'slate-800',
inputBorder: 'slate-600',
overlay: 'slate-900/80',
inputBorder: 'blue-800',
overlay: 'slate-900/50',
},
},
light: {
@@ -0,0 +1,38 @@
/** Strategy 模块外部 iframe / 嵌入页链接(来自 Vite 环境变量) */
function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
}
function readEnv(key: string): string {
const value = import.meta.env[key];
return typeof value === "string" ? value.trim() : "";
}
/** B2 网络 xwt 资源管理 iframe 基础地址 */
export function getB2ActorBaseUrl(): string {
return trimTrailingSlash(readEnv("VITE_AIPI_B2ACTOR_URL"));
}
/** B2 网络 xwt 资源管理 iframe 路径 */
export function getNetworkActorIframePath(): string {
return readEnv("VITE_AIPI_B2ACTOR_XWT_PATH") || "/fromB/toXWT?isFrame=1";
}
/** 网络 xwt 资源管理完整 iframe URL */
export function getNetworkActorIframeUrl(): string {
const base = getB2ActorBaseUrl();
const path = getNetworkActorIframePath();
if (!base) return "";
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
/** 知识库 iframe 地址换取 API */
export function getKnowledgeIframeApiUrl(): string {
return readEnv("VITE_AIPI_KNOWLEDGE_IFRAME_API_URL");
}
/** 知识库 iframe 皮肤参数 */
export function getKnowledgeIframeSkin(): string {
return readEnv("VITE_AIPI_KNOWLEDGE_IFRAME_SKIN") || "red";
}
+1
View File
@@ -1,4 +1,5 @@
import "./styles/globals.css";
import "tdesign-react/es/style/index.css";
import "katex/dist/katex.min.css";
import React from "react";
@@ -38,6 +38,8 @@ const ENABLE_PAUSE_RESUME_UI = false;
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { SkillStepsCard } from './SkillStepsCard';
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { IntentResultCard } from './IntentResultCard';
@@ -411,6 +413,8 @@ export function AIWritingPanel({
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
revisionCount={state.revisionCount}
skillSearchSteps={state.skillSearchSteps}
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
onSubmit={submitIntervention}
disabled={false}
/>
@@ -444,10 +448,27 @@ export function AIWritingPanel({
/>
)}
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空 */}
{state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && (
<SkillSelectStreamingView
text={state.skillSelectStreamingText}
thinkingText={state.skillSelectThinkingText}
/>
)}
{/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */}
{state.status === 'researching' && state.skillSearchSteps.length > 0 && (
<div className="px-4 pb-2">
<SkillStepsCard steps={state.skillSearchSteps} inProgress />
</div>
)}
{showSkeleton &&
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */
!(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
Trash2, BookOpen,
Trash2, BookOpen, Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -10,11 +10,13 @@ import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
ProgressEvent, UserInterventionPayload,
BlockedSection, SectionDecisionKind,
SkillSearchStep,
} from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
import { SkillStepsCard } from './SkillStepsCard';
interface InterventionCardProps {
pauseEvent: ProgressEvent;
@@ -23,18 +25,29 @@ interface InterventionCardProps {
reviewResult: ReviewResult | null;
draftTitle: string;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, onSubmit, disabled,
draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
return (
<MaterialConfirmCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'outline_confirm') {
@@ -106,30 +119,40 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
interface MaterialConfirmCardProps {
materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
const [userQuery, setUserQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
const hasExtra = extraKeywords.length > 0;
async function handleEnableSkillSearch() {
setSubmitting(true);
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
const hasQuery = userQuery.trim().length > 0;
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
if (materials.length === 0) {
@@ -140,33 +163,45 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
</p>
</div>
</div>
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set())}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
userQuery={userQuery}
onUserQueryChange={setUserQuery}
onSubmitQuery={handleReSearch}
disabled={disabled || submitting}
/>
<div className="flex gap-2 pt-1">
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleConfirm} disabled={disabled || submitting}
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />无数据继续写作
</Button>
@@ -182,11 +217,14 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
</p>
</div>
</div>
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -200,24 +238,34 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
userQuery={userQuery}
onUserQueryChange={setUserQuery}
onSubmitQuery={handleReSearch}
disabled={disabled || submitting}
/>
<div className="flex gap-2 pt-1">
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
{!isSkillResult && (
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronRight, ExternalLink } from 'lucide-react';
import { ChevronRight, ExternalLink, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { buildIntranetSourceUrl } from '@/core/config';
import type { AIMaterial } from '../../types/ai-writing';
@@ -26,12 +26,14 @@ function relevanceLabel(score: number): string {
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
const [expanded, setExpanded] = useState(false);
const pct = Math.round(material.relevanceScore * 100);
const isSkill = material.sourceType === 'skill';
return (
<div
className={cn(
'rounded-md border text-xs transition-colors',
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
isSkill && checked && 'border-violet-300/70 dark:border-violet-800/60',
)}
>
{/* 头部行 */}
@@ -50,6 +52,12 @@ export function MaterialItem({ material, checked, onToggle }: MaterialItemProps)
onClick={() => setExpanded((v) => !v)}
className="flex flex-1 items-start gap-1.5 text-left"
>
{isSkill && (
<span className="mt-0.5 inline-flex items-center gap-0.5 px-1 py-0.5 rounded text-[10px] font-medium bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-200 shrink-0">
<Sparkles className="size-2.5" />
技能
</span>
)}
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
{material.title}
</span>
@@ -1,6 +1,4 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useRef } from 'react';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialsToolbarProps {
@@ -8,9 +6,13 @@ interface MaterialsToolbarProps {
selectedIds: Set<string>;
onSelectAll: () => void;
onDeselectAll: () => void;
extraKeywords: string[];
onAddKeyword: (kw: string) => void;
onRemoveKeyword: (kw: string) => void;
/** 单输入框文本:可以是技能名、检索来源、补充检索意图 */
userQuery: string;
onUserQueryChange: (text: string) => void;
/** 回车键提交(默认重新搜索;详细按钮交给父组件渲染) */
onSubmitQuery?: () => void;
placeholder?: string;
disabled?: boolean;
}
export function MaterialsToolbar({
@@ -18,18 +20,27 @@ export function MaterialsToolbar({
selectedIds,
onSelectAll,
onDeselectAll,
extraKeywords,
onAddKeyword,
onRemoveKeyword,
userQuery,
onUserQueryChange,
onSubmitQuery,
placeholder,
disabled,
}: MaterialsToolbarProps) {
const [kwInput, setKwInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
function handleAddKeyword() {
const kw = kwInput.trim();
if (!kw || extraKeywords.includes(kw)) return;
onAddKeyword(kw);
setKwInput('');
function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
onUserQueryChange(e.target.value);
const el = e.target;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey && onSubmitQuery) {
e.preventDefault();
onSubmitQuery();
}
}
return (
@@ -51,50 +62,21 @@ export function MaterialsToolbar({
</div>
)}
{/* 补充关键词 */}
{/* 单输入框 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
<div className="flex gap-1.5">
<input
type="text"
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
placeholder="输入关键词后按 Enter 或点击 +"
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handleAddKeyword}
disabled={!kwInput.trim()}
>
<Plus className="size-3" />
</Button>
</div>
{/* 已添加关键词 Tag 列表 */}
{extraKeywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{extraKeywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
onClick={() => onRemoveKeyword(kw)}
className="hover:text-destructive leading-none"
>
×
</button>
</span>
))}
</div>
)}
<label className="text-xs text-muted-foreground">
补充检索(可选)— 写下你想检索的内容,然后点"重新搜索"或"技能检索"
</label>
<textarea
ref={textareaRef}
rows={1}
value={userQuery}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? '例如:想查看各大平台销量 / 2025 年财报 / 行业头部公司近期动态…'}
disabled={disabled}
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
/>
</div>
</div>
);
@@ -0,0 +1,158 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Sparkles, ChevronDown, ChevronRight, Brain, Zap } from 'lucide-react';
interface SkillSelectStreamingViewProps {
text: string;
thinkingText?: string;
}
/**
* 从流式 JSON 文本里增量提取已经识别出的技能名。
*
* LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`,
* 我们不等整段 JSON 解析完,逐字符扫描"已闭合"的双引号字符串:
* - 找到 `"selected_skills"`(或末尾不完整时回退兜底)后取后续内容
* - 在该位置之后的 `[ ... ]` 内逐个匹配 `"..."`
* - 只取**已闭合**的字符串(结尾必须是 `"`),确保不把正在打字的半截名字露出来
*/
function extractStreamingSkillNames(text: string): string[] {
if (!text) return [];
// 定位 selected_skills 的值起点(容错:允许中英文引号 / 不同空白)
const keyMatch = text.match(/["']?selected_skills["']?\s*:\s*\[/);
if (!keyMatch) return [];
const start = keyMatch.index! + keyMatch[0].length;
// 截取数组段(找到对应的 ],或者文本结尾)
const rest = text.slice(start);
const endIdx = rest.indexOf(']');
const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest;
// 只提取已经"闭合"的字符串:[^"\\]*(?:\\.[^"\\]*)* 处理转义引号
const names: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(arrBody)) !== null) {
const name = m[1].trim();
if (name) names.push(name);
}
return names;
}
/**
* 技能选择 LLM 流式打字机视图。
*
* - 思考过程(<think></think> 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致)
* - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义)
* - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标
*/
export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
// 实时从流式 JSON 中增量解析出已识别的技能名
const streamingSkills = useMemo(() => extractStreamingSkillNames(text), [text]);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}, [thinkingText, thinkOpen]);
if (!text && !thinkingText) return null;
return (
<div className="px-4 pb-2 space-y-2">
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
思考过程
</span>
<span className="ml-auto text-amber-400">
{thinkOpen
? <ChevronDown className="size-3.5" />
: <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{thinkingText}
{!text && (
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
{text && (
<div className="rounded-xl border border-violet-200 dark:border-violet-900 bg-violet-50/50 dark:bg-violet-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-200/60 dark:border-violet-900/60 bg-violet-100/40 dark:bg-violet-900/30">
<Sparkles className="size-3.5 text-violet-500 shrink-0" />
<span className="text-xs font-medium text-violet-600 dark:text-violet-400">
正在筛选相关技能
{streamingSkills.length > 0 && (
<span className="ml-1 text-violet-500/80">
· 已识别 {streamingSkills.length} 个
</span>
)}
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-violet-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
{/* 已识别的技能名:每个新出现的 badge 会 fade-in,给用户即时反馈 */}
{streamingSkills.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-3 py-2 border-b border-violet-200/40 dark:border-violet-900/40 bg-violet-50/40 dark:bg-violet-950/10">
{streamingSkills.map((name, idx) => (
<span
key={`${name}-${idx}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
style={{ animationFillMode: 'both' }}
>
<Zap className="size-2.5 shrink-0" />
{name}
</span>
))}
</div>
)}
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
>
{text}
<span className="inline-block w-0.5 h-3.5 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes skill-fade-in {
from { opacity: 0; transform: translateY(-2px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
);
}
@@ -0,0 +1,129 @@
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SkillSearchStep } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition';
interface SkillStepsCardProps {
steps: SkillSearchStep[];
/** 是否仍在进行中(researching 状态下显示 spinner,让用户看到流式进展) */
inProgress?: boolean;
}
const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
list_skills: ListChecks,
select_skills: Sparkles,
invoke_skill: Zap,
skill_result: CheckCircle2,
stop_early: CircleStop,
};
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
list_skills: '列出可用技能',
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
skill_result: '检索结果',
stop_early: '提前停止',
};
export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
const [expanded, setExpanded] = useState(true);
if (steps.length === 0 && !inProgress) return null;
return (
<div className="rounded-md border border-violet-200 bg-violet-50/40 dark:border-violet-900/40 dark:bg-violet-950/20 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-violet-100/50 dark:hover:bg-violet-900/30 transition-colors text-left"
>
{inProgress
? <Loader2 className="size-3.5 text-violet-500 shrink-0 animate-spin" />
: <Sparkles className="size-3.5 text-violet-500 shrink-0" />}
<span className="flex-1">
技能检索过程
<span className="ml-1 text-muted-foreground font-normal">
(共 {steps.length} 步{inProgress ? ',进行中…' : ''})
</span>
</span>
<ChevronRight
className={cn(
'size-3 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<CollapseTransition open={expanded}>
<ol className="px-3 pb-2.5 pt-1 space-y-1.5 border-t border-violet-200/60 dark:border-violet-900/30">
{steps.map((step, idx) => {
const Icon = STEP_ICON[step.type] ?? Sparkles;
const label = STEP_LABEL[step.type] ?? step.type;
const isStopEarly = step.type === 'stop_early';
const isResult = step.type === 'skill_result';
return (
<li key={idx} className="flex items-start gap-2 text-[11px]">
<span
className={cn(
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
isStopEarly
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
)}
>
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<div
className={cn(
'flex items-center gap-1 font-medium',
isStopEarly
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
)}
>
<Icon className="size-3 shrink-0" />
<span>{label}</span>
{step.skillName && (
<span className="font-mono text-violet-900 dark:text-violet-100">
· {step.skillName}
</span>
)}
</div>
<div className="text-muted-foreground mt-0.5">{step.message}</div>
{step.skillNames && step.skillNames.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{step.skillNames.map((name) => (
<span
key={name}
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200"
>
{name}
</span>
))}
</div>
)}
{step.reason && (
<div className="mt-0.5 text-[10px] text-violet-600/80 dark:text-violet-400/80 italic">
原因:{step.reason}
</div>
)}
</div>
</li>
);
})}
{inProgress && (
<li className="flex items-center gap-2 text-[11px] text-muted-foreground italic">
<Loader2 className="size-3 animate-spin" />
更多步骤生成中…
</li>
)}
</ol>
</CollapseTransition>
</div>
);
}
@@ -41,6 +41,7 @@ import type {
ProgressEventType,
ReviewIssue,
ReviewResult,
SkillSearchStep,
StreamingReviewItem,
UserInterventionPayload,
WritingStatus,
@@ -71,6 +72,9 @@ const INITIAL_STATE: AIWritingState = {
intentThinkingText: '',
parsedIntent: null,
researchProgress: '',
skillSearchSteps: [],
skillSelectStreamingText: '',
skillSelectThinkingText: '',
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
@@ -105,7 +109,7 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
...prev,
currentPause: null,
status: ns,
...(ns === 'researching' ? { researchProgress: '' } : {}),
...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}),
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
@@ -191,12 +195,36 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'resumed':
return { ...base, currentPause: null };
case 'materials_ready':
case 'skill_search_step': {
const step = event.skillStep;
if (!step) return base;
// select_skills 步骤到达 = LLM 选择完成,清空流式打字缓冲
const clearStreaming = step.type === 'select_skills'
? { skillSelectStreamingText: '', skillSelectThinkingText: '' }
: {};
return { ...base, skillSearchSteps: [...prev.skillSearchSteps, step], ...clearStreaming };
}
case 'skill_select_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
skillSelectStreamingText: isThinking ? prev.skillSelectStreamingText : prev.skillSelectStreamingText + text,
skillSelectThinkingText: isThinking ? prev.skillSelectThinkingText + text : prev.skillSelectThinkingText,
};
}
case 'materials_ready': {
// 后端把 pkg.skill_search_steps 一并塞回来时,覆盖前端累积(保证刷新页面回放也对)
const incomingSteps = (event as unknown as { skillSearchSteps?: SkillSearchStep[] }).skillSearchSteps;
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}),
};
}
case 'outline_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
@@ -337,6 +365,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
// ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ──────
function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
const extra = (m.extra_metadata ?? m.extraMetadata) as Record<string, unknown> | undefined;
return {
id: String(m.id ?? ''),
title: String(m.title ?? ''),
@@ -344,9 +373,21 @@ function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
source: String(m.source ?? ''),
publishedAt: String(m.published_at ?? m.publishedAt ?? ''),
relevanceScore: Number(m.relevance_score ?? m.relevanceScore ?? 0),
sourceType: (m.source_type ?? m.sourceType ?? 'internet') as 'internet' | 'intranet',
sourceType: (m.source_type ?? m.sourceType ?? 'internet') as AIMaterial['sourceType'],
url: m.url as string | undefined,
recUuid: (m.recUuid ?? m.rec_uuid) as string | undefined,
extraMetadata: extra,
};
}
function normalizeSkillStep(s: Record<string, unknown>): SkillSearchStep {
return {
type: (s.type as SkillSearchStep['type']) ?? 'invoke_skill',
message: String(s.message ?? ''),
skillNames: (s.skill_names ?? s.skillNames) as string[] | undefined,
skillName: (s.skill_name ?? s.skillName) as string | undefined,
reason: s.reason as string | undefined,
matchedCount: (s.matched_count ?? s.matchedCount) as number | undefined,
};
}
@@ -445,6 +486,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
'intent_chunk', 'intent_ready',
'research_progress', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done',
'skill_search_step', 'skill_select_chunk',
];
if (!allowed.includes(type as ProgressEventType)) return null;
@@ -459,6 +501,10 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
if (Array.isArray(merged.issues)) {
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
}
// skill_search_step:把字段折成 skillStep 子对象,方便 reducer / UI 取用
if (type === 'skill_search_step') {
merged.skillStep = normalizeSkillStep(o);
}
return merged as unknown as ProgressEvent;
}
@@ -478,6 +524,9 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
// ``material_package`` 出现 → materials_ready(节点 progress_events 里不带)
if (out.material_package && typeof out.material_package === 'object') {
const pkg = out.material_package as Record<string, unknown>;
const skillSteps = Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps)
? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep)
: undefined;
events.push({
type: 'materials_ready',
sessionId,
@@ -485,7 +534,8 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
materials: Array.isArray(pkg.materials) ? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [],
keywords: (pkg.keywords ?? []) as string[],
materialSummary: (pkg.summary as string) ?? '',
});
...(skillSteps ? { skillSearchSteps: skillSteps } as unknown as Partial<ProgressEvent> : {}),
} as ProgressEvent);
}
// ``current_outline`` 出现且 progress_events 里没 outline_ready → 合成一条
@@ -733,7 +783,9 @@ export function useAIWriting() {
// 乐观更新到下一阶段,跟老 hook 行为对齐
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
// re_search / enable_skill_search 都回到 researching;confirm 才进大纲
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
@@ -754,6 +806,7 @@ export function useAIWriting() {
};
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
@@ -7,9 +7,26 @@ export interface AIMaterial {
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet';
sourceType: 'internet' | 'intranet' | 'skill';
url?: string;
recUuid?: string;
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
extraMetadata?: Record<string, unknown>;
}
// 技能检索过程的单步记录(前端时间线展示"使用了 X 技能")
// list_skills:列出全部 enabled 技能
// select_skills:LLM 按相关度排序的候选
// invoke_skill:开始调用某个技能去检索
// skill_result:某个技能检索返回了 N 条素材
// stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep {
type: 'list_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
message: string;
skillNames?: string[];
skillName?: string;
reason?: string;
matchedCount?: number;
}
// ── 大纲 ─────────────────────────────────────────────────────────────────────
@@ -139,6 +156,8 @@ export type ProgressEventType =
| 'await_user'
| 'resumed'
| 'notice'
| 'skill_search_step'
| 'skill_select_chunk'
| 'done'
| 'error';
@@ -173,6 +192,8 @@ export interface ProgressEvent {
pauseContext?: Record<string, unknown>;
// section_help:素材不足、待用户决策的章节
blockedSections?: BlockedSection[];
// skill_search_step:技能检索步骤事件(list_skills / select_skills / invoke_skill)
skillStep?: SkillSearchStep;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
@@ -180,7 +201,10 @@ export interface ProgressEvent {
export interface UserInterventionPayload {
action: string;
approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(技能名/来源/检索意图都行)。re_search / enable_skill_search 共用。 */
userQuery?: string;
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
@@ -291,6 +315,11 @@ export interface AIWritingState {
parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线)
skillSearchSteps: SkillSearchStep[];
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
skillSelectStreamingText: string;
skillSelectThinkingText: string;
// 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[];
// 修改摘要
+105 -35
View File
@@ -19,6 +19,13 @@ import {
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
@@ -115,10 +122,34 @@ export default function NewAgentPage() {
return;
}
const first = models[0]!.name;
setModelInput((prev) => prev || first);
setChatModelInput((prev) => prev || first);
setModelInput((prev) =>
prev && models.some((m) => m.name === prev) ? prev : first,
);
setChatModelInput((prev) =>
prev && models.some((m) => m.name === prev) ? prev : first,
);
}, [models]);
const resolvedModelInput = useMemo(() => {
if (models.length === 0) {
return "";
}
if (modelInput && models.some((m) => m.name === modelInput)) {
return modelInput;
}
return models[0]!.name;
}, [modelInput, models]);
const resolvedChatModelInput = useMemo(() => {
if (models.length === 0) {
return "";
}
if (chatModelInput && models.some((m) => m.name === chatModelInput)) {
return chatModelInput;
}
return models[0]!.name;
}, [chatModelInput, models]);
const [thread, sendMessage] = useThreadStream({
threadId,
assistantId: agentId || undefined,
@@ -347,23 +378,42 @@ export default function NewAgentPage() {
<span className="text-muted-foreground text-xs">
当前对话模型
</span>
<select
value={chatModelInput}
onChange={(event) =>
setChatModelInput(event.currentTarget.value)
}
disabled={isModelsLoading || thread.isLoading}
className="border-input bg-background h-7 min-w-[10rem] rounded-md border px-2 text-xs shadow-xs outline-none"
>
<option value="" disabled>
选择模型
</option>
{models.map((model) => (
<option key={model.name} value={model.name}>
{model.display_name || model.name}
</option>
))}
</select>
{isModelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{isModelsLoading ? "加载中…" : "暂无模型"}
</div>
) : (
<Select
value={resolvedChatModelInput}
onValueChange={setChatModelInput}
disabled={thread.isLoading}
>
<SelectTrigger
className={cn(
"border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
"data-[placeholder]:text-muted-foreground",
"[&_svg]:dark:text-[#AACEE2]",
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]"
>
{models.map((model) => (
<SelectItem
key={model.name}
value={model.name}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
@@ -415,21 +465,41 @@ export default function NewAgentPage() {
<div className="space-y-2">
<label className="text-sm font-medium">模型</label>
<select
value={modelInput}
onChange={(event) => setModelInput(event.currentTarget.value)}
disabled={isModelsLoading}
className="border-input bg-background h-9 w-full rounded-md border px-3 text-sm shadow-xs outline-none"
>
<option value="" disabled>
选择模型
</option>
{models.map((model) => (
<option key={model.name} value={model.name}>
{model.display_name || model.name}
</option>
))}
</select>
{isModelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-9 w-full items-center rounded-md border px-3 text-sm shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{isModelsLoading ? "加载模型…" : "暂无可用模型"}
</div>
) : (
<Select
value={resolvedModelInput}
onValueChange={setModelInput}
>
<SelectTrigger
className={cn(
"border-input bg-background h-9 w-full shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
"data-[placeholder]:text-muted-foreground",
"[&_svg]:dark:text-[#AACEE2]",
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]"
>
{models.map((model) => (
<SelectItem
key={model.name}
value={model.name}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-muted-foreground text-xs">
该智能体被使用时调用的模型。
</p>
@@ -463,7 +533,7 @@ export default function NewAgentPage() {
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2 dark:border-[#1B477E] dark:bg-[#022248]">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
+80 -6
View File
@@ -5,7 +5,7 @@ import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import { SidebarProvider } from "@/components/ui/sidebar";
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
import { ChatRuntime } from "./WorkspaceRoutes";
import AgentsPage from "./AgentsPage";
import ChatsPage from "./ChatsPage";
@@ -30,6 +30,15 @@ import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom";
import FanganTyPage from "@/strategy-components/components/task-components/FanganTy";
import XingdongTyPage from "@/strategy-components/components/task-components/XingdongTy";
import XXTyPage from "@/strategy-components/components/task-components/XXTy";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
export default function StrategyRoutes() {
return (
@@ -41,26 +50,91 @@ export default function StrategyRoutes() {
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
<Route
path="qa/expert-qa"
element={
<GeneralQA
chatHeadTitle="专家问答"
chatDesign="支持单轮RAG、多轮RAG、深度检索及结果对比"
/>
}
/>
{/* 任务管理 */}
<Route path="task-management" element={<TaskManagement />}>
<Route index element={<Navigate to="xk-task-jc" replace />} />
{/* XK任务 */}
<Route
path="xk-task-jc"
element={<TaskXkFrom gopath="#/pageIndie/pageHome/pageSwrw" />}
/>
<Route
path="xk-task-dispatch"
element={<TaskXkFrom gopath="#/pageIndie/pageJczl" />}
/>
{/* pg中任务 */}
<Route
path="effect-task-pg"
element={<TaskPgFrom gopath="task" />}
/>
<Route
path="effect-action-pg"
element={<TaskPgFrom gopath="xd" />}
/>
<Route
path="effect-info-pg"
element={<TaskPgFrom gopath="info" />}
/>
{/* COH中任务 */}
<Route
path="plan-task-sentiment"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
<SentimentAnalyzes
title="3QFX"
category="规划任务"
subCategory="规划任务"
/>
}
/>
<Route
path="xk-task-dispatch"
path="plan-task/plan-task-fx"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
<TaskAnalyzes
title="任务FX"
category="规划任务"
subCategory="规划任务"
/>
}
/>
<Route
path="plan-task/plan-task-action"
element={
<ActionAnalyzes
title="xdfx"
category="规划任务"
subCategory="规划任务"
/>
}
/>
{/* MNTY任务 */}
<Route path="mnty-task-mechanism" element={<XingdongTyPage />} />
<Route path="mnty-task-plan" element={<FanganTyPage />} />
<Route path="mnty-task-zsty" element={<XXTyPage />} />
</Route>
{/* 资源管理 */}
<Route
path="resource-management/resource-network-actor"
element={<NetworkActorResourceManagement />}
/>
<Route
path="resource-management/resource-knowledge-base"
element={<KnowledgeBaseResourceManagement />}
/>
<Route
path="resource-management/resource-evaluation-index"
element={
<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />
}
/>
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
@@ -68,13 +68,20 @@ export interface RecommendCandidate {
* Agent ids that must never enter the candidate pool. Centralized here so
* every caller (recommend dialog, future tools) applies the same rule.
*
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
* - `roundtable-recommender`— the recommender itself, can't recommend itself.
* - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB
* accumulates one per session); meaningless as roundtable participants.
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
* - `roundtable-recommender` — the recommender itself, can't recommend itself.
* - `roundtable-coordinator` — Step 2 coordinator singleton (2026-05 改造后固定 id,
* ensure 自动落地;它是"协调者",不能反过来作为席位被协调)。
* - `roundtable-coordinator-*` 前缀 — 历史残留:2026-05 之前每次 init 会创建
* `roundtable-coordinator-<ts36>` 形式的临时 agent,DB 仍可能保留这些遗留记录。
* 保留前缀过滤可以在管理员清理之前先把它们隐藏起来,避免出现在推荐弹窗候选池里。
*/
const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-";
const EXCLUDED_AGENT_IDS = new Set<string>(["roundtable-intent", "roundtable-recommender"]);
const EXCLUDED_AGENT_IDS = new Set<string>([
"roundtable-intent",
"roundtable-recommender",
"roundtable-coordinator",
]);
/**
* Drop excluded ids from a raw `GET /api/agents` list and project each entry
@@ -36,7 +36,9 @@ import {
type RecommendCandidate,
type RecommendStreamPhase,
} from "../api/recommend";
import { useAutoScroll } from "../hooks/useAutoScroll";
import { splitInlineReasoning } from "../lib/reasoning";
import { ScrollToBottomButton } from "./ScrollToBottomButton";
/**
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
@@ -453,6 +455,7 @@ export function RecommendAgentsDialog({
state.phase === "loading_agents" || state.phase === "recommending"
}
phase={state.phase}
scrollDeps={[state.rationale, state.steps, state.streamPhase]}
>
<RecommenderOutput state={state} />
</CollapsibleModelOutput>
@@ -593,16 +596,22 @@ const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
function CollapsibleModelOutput({
isLoading,
phase,
scrollDeps,
children,
}: {
/** True 时面板保持展开 + header 显示 spinner;涵盖加载候选 + 流式生成。 */
isLoading: boolean;
phase: DialogPhase;
/** 内容变化时触发自动滚到底(与 Step1/Step2 对话区同一套 useAutoScroll)。 */
scrollDeps: React.DependencyList;
children: React.ReactNode;
}) {
const [open, setOpen] = useState<boolean>(true);
const userTouchedRef = useRef<boolean>(false);
const hasAutoClosedRef = useRef<boolean>(false);
const modelOutputScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: modelOutputAtBottom, scrollToBottom: scrollModelOutputToBottom } =
useAutoScroll(modelOutputScrollRef, scrollDeps);
// 加载中 → 保持展开(用户没手动动过的前提下)。
// 加载完成 → 延迟自动收起一次(仅一次),让出焦点给下方推荐卡片。
@@ -677,8 +686,16 @@ function CollapsibleModelOutput({
/>
</button>
{open && (
<div className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
{children}
<div className="relative min-h-0 flex-1 flex flex-col">
<div
ref={modelOutputScrollRef}
className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar"
>
{children}
</div>
{!modelOutputAtBottom && (
<ScrollToBottomButton onClick={scrollModelOutputToBottom} />
)}
</div>
)}
</section>
@@ -751,7 +768,10 @@ function RecommenderOutput({ state }: { state: InternalState }) {
{hasReasoning && (
<ReasoningBlock
streaming={isStreaming}
contentClassName="max-h-48 overflow-y-auto custom-scrollbar"
contentClassName={cn(
"overflow-y-auto custom-scrollbar",
isStreaming ? "max-h-none" : "max-h-48",
)}
>
{reasoning!}
</ReasoningBlock>
@@ -762,7 +782,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{hasRationale && (
<div className="max-h-40 overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<div
className={cn(
"overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed",
isStreaming ? "max-h-none" : "max-h-40",
)}
>
<MarkdownContent>{cleanedRationale}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
@@ -833,16 +833,19 @@ export function useStep2Orchestration(
const markSpeakerStreaming = useCallback(
(speakerId: number) => {
setActiveSpeakerSet((prev) => {
if (prev.has(speakerId)) return prev;
const next = new Set(prev);
next.add(speakerId);
return next;
});
// 编排循环已经改成**顺序**派 special(详见 §5.4 / 文件顶部注释),
// 任意时刻只有一个子智能体在跑。因此这里既要把当前发言席位置为"研讨中",
// 又要把上一轮 / 历史残留下来的"研讨中"统一清成"在线",保证右侧"参与角色"
// 面板任何时刻**只有一个** sub agent 显示研讨中,与对话区实际在 stream 的
// 那一个一致。activeSpeakerSet 沿用 Set 形态(虽然实际只放 1 个)以兼容外部
// 消费方,真正的"高亮排他"靠 roles 的 status reset。
setActiveSpeakerSet(() => new Set([speakerId]));
setRoles((prev) =>
prev.map((item) =>
item.id === speakerId ? { ...item, status: "研讨中" } : item,
),
prev.map((item) => {
if (item.id === speakerId) return { ...item, status: "研讨中" };
if (item.status === "研讨中") return { ...item, status: "在线" };
return item;
}),
);
},
[setRoles],
@@ -912,7 +915,14 @@ export function useStep2Orchestration(
safetyCounter < MAX_CYCLES
) {
safetyCounter += 1;
highlightSpeaker(2);
// Leader(协调智能体)正在派活,**不应该**让任何 sub agent 显示"研讨中"——
// 协调者本身不在右侧"参与角色"面板里(COMMANDER_SEAT_ID=1 是人类主持人,
// sub agent 从 id=2 起)。历史代码这里写的是 `highlightSpeaker(2)`,会
// 错把第一个 sub agent 误标为研讨中,叠加 special 派活时另一个 sub 也被
// markSpeakerStreaming,就出现"两个 sub 同时研讨中"的视觉 bug。
// 改成 null 后:leader 跑的阶段所有 sub 都是"在线",special 跑哪个就只
// 高亮哪个。activeSpeakerId 同步置空,圆桌可视化也不会高亮错的位置。
highlightSpeaker(null);
// 总控也可能直接 write_file —— 之前只接了 special 流,导致总控写文件时沙箱不自动打开。
// 这里把 leader thread id 绑到气泡 + artifact handler 上,与 special 同样的行为。
@@ -130,8 +130,27 @@ export function nowTime(): string {
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
/**
* 圆桌总控协调智能体的**固定单例 id**。后端 `_roundtable_seed.COORDINATOR_AGENT_ID`
* 与本常量必须保持一致(2026-05 改造前是 `roundtable-coordinator-<ts36>` 形式,
* 每次会话生成新 id 导致 `.deer-flow/agents/` 累积大量壳目录)。
*
* 改造后:
* - 后端 ensure 机制保证磁盘上始终存在 `.deer-flow/agents/roundtable-coordinator/`;
* - 本次会话的「可调度席位列表」由后端 init 通过 human 消息注入 coordinator
* thread 历史,不再写入 SOUL,所以单例 SOUL 长期稳定;
* - 前端 `streamMultiAgent({ coordinatorName })` 等位置不需要任何改动,因为
* `coordinatorName` 仍然是一个**字符串**,只是值不再每次变化。
*/
export const COORDINATOR_AGENT_ID = "roundtable-coordinator";
/**
* 兼容性保留:历史上叫 `newCoordinatorName()`,意为"生成新的一次性 coordinator id"。
* 改造为单例后实际只返回常量;命名不再准确,但留下避免触发跨文件级联改名。
* 新代码请直接使用 {@link COORDINATOR_AGENT_ID}。
*/
export function newCoordinatorName(): string {
return `roundtable-coordinator-${Date.now().toString(36)}`;
return COORDINATOR_AGENT_ID;
}
/**
@@ -0,0 +1,47 @@
import service from "@/utils/request";
export const requestCrudService = {
get: async (endpoint, params = {}) => {
try {
const response = await service.get(endpoint, { params });
return response;
} catch (error) {
throw new Error(`GET request failed: ${error.message}`);
}
},
post: async (endpoint, data = {}) => {
try {
const response = await service.post(endpoint, data);
return response;
} catch (error) {
throw new Error(`POST request failed: ${error.message}`);
}
},
put: async (endpoint, data = {}) => {
try {
const response = await service.put(endpoint, data);
return response;
} catch (error) {
throw new Error(`PUT request failed: ${error.message}`);
}
},
update: async (endpoint, data = {}) => {
try {
const response = await service.patch(endpoint, data);
return response;
} catch (error) {
throw new Error(`patch request failed: ${error.message}`);
}
},
delete: async (endpoint) => {
try {
const response = await service.delete(endpoint);
return response;
} catch (error) {
throw new Error(`DELETE request failed: ${error.message}`);
}
}
}
@@ -0,0 +1,141 @@
import { environment } from '@/environments/environment';
import { requestCrudService } from '@/service/request-axios-service';
export interface GetOneParam {
fields?: string[]; // 指定返回的字段 id,name
join?: string[]; // 指定关联表,如 ['user||id,name', 'relation||id']
cache?: 0 | 1; // 0直接DB,1缓存,默认1缓存
}
export interface GetAllParam {
fields?: string[] | string; // 指定返回的字段: 多个字段如 'id', 'name'
filter?: string[]; // 指定过滤条件,如 ['name||$eq||batman', 'id||$in||1,2,3', 'field||$isnull', 'relation.id||$eq||1']
or?: string[]; // 同filter
join?: string[]; // 指定关联表,如 ['user||id,name', 'relation||id']
cache?: 0 | 1; // 0直接DB,1缓存,默认1缓存
page?: number; // 页码(会被转换成offset)
offset?: number; // 偏移量
limit?: number; // 每页数量
sort?: string[]; // 排序,如 '['name,ASC', 'id,DESC]'
s?: string;
joinTaskAction?: boolean; //是否连行动表查询
joinTaskActionBoci?: boolean; //策略分析列表使用
}
export interface GetAllResponse {
data: any[];
page: number;
pageCount: number;
count: number;
total: number;
errMsg?: string;
}
let deepCopy = (str: any) => {
return JSON.parse(JSON.stringify(str));
}
export class RequestCrudService {
/**
* 获取单个对象
* @param url 地址
* @param id ID
* @param params 参数
* @returns 单个对象
*/
async getOne(url: string, id: number, params?: GetOneParam): Promise<any> {
const getUrl = `${url}/${id}`;
const res = await requestCrudService.get(getUrl, params);
if (res) return res;
// this.notification.create('error', getUrl, `${res.data}`);
return {};
}
/**
* 获取多个对象
* @param url 地址
* @param params 参数
* @returns 多个对象数组
*/
async getAll(url: string, params: GetAllParam | null = null): Promise<GetAllResponse> {
const formattedParams = deepCopy(params || {});
// 分页默认值
if (!params || !params.limit) formattedParams.limit = 999;
if (!params || !params.page) formattedParams.page = 1;
// 计算offset
if (params && formattedParams.page) {
formattedParams.offset = (formattedParams.page - 1) * formattedParams.limit!;
delete formattedParams.page;
}
const res = await requestCrudService.get(url, formattedParams);
if (res.status === 200) return res.data;
// this.notification.create('error', url, `${res.data}`);
return {
count: 0,
data: [],
page: 1,
pageCount: 1,
total: 0
};
}
/**
* 创建单个对象
* @param url 地址
* @param data 单个对象
* @returns 被创建的单个对象
*/
async createOne(url: string, data: any) {
const res = await requestCrudService.post(url, data);
if (res.status === 201) return res.data;
// this.notification.create('error', url, `${res.data}`);
return {};
}
/**
* 创建多个对象
* @param url 地址
* @param data 多个对象
* @returns 被创建的多个对象
*/
async createMulti(url: string, data: any[]) {
const res = await requestCrudService.post(url, {
bulk: data
});
if (res.status === 201) return res.data;
// this.notification.create('error', url, `${res.data}`);
return [];
}
/**
* 更新单个对象
* @param url 地址
* @param id ID
* @param data 单个对象部分键值对
* @returns 被修改的单个对象
*/
async updateOne(url: string, id: number, data: any) {
const res = await requestCrudService.update(`${url}/${id}`, data);
if (res.status === 200) return res.data;
// this.notification.create('error', url, `${res.data}`);
return {};
}
/**
* 删除单个对象
* @param url 地址
* @param id ID
* @returns 操作结果
*/
async deleteOne(url: string, id: number) {
const res = await requestCrudService.delete(`${url}/${id}`);
if (res.status === 200) return res.data;
// this.notification.create('error', url, `${res.data}`);
return 'error';
}
// get 请求
async simpleGet(url: string, params?: any): Promise<any> {
const res = await requestCrudService.get(url, params);
if (res.status === 200) return res.data;
return res.data;
}
// post 请求
async simplePost(url: string, data: any) {
const res = await requestCrudService.post(url, data);
if (res.status === 200) return res.data;
return res.data;
}
}
@@ -0,0 +1,56 @@
import toolsService from "@/utils/tools-request";
const service = toolsService;
export const toolsCrudService = {
get: async (endpoint, params = {}) => {
try {
const response = await service.get(endpoint, { params });
return response;
} catch (error) {
throw new Error(`GET request failed: ${error.message}`);
}
},
post: async (endpoint, data = {}) => {
try {
const response = await service.post(endpoint, data);
return response;
} catch (error) {
throw new Error(`POST request failed: ${error.message}`);
}
},
put: async (endpoint, data = {}) => {
try {
const response = await service.put(endpoint, data);
return response;
} catch (error) {
throw new Error(`PUT request failed: ${error.message}`);
}
},
patch: async (endpoint, data = {}) => {
try {
const response = await service.patch(endpoint, data);
return response;
} catch (error) {
throw new Error(`PATCH request failed: ${error.message}`);
}
},
update: async (endpoint, data = {}) => {
try {
const response = await service.patch(endpoint, data);
return response;
} catch (error) {
throw new Error(`patch request failed: ${error.message}`);
}
},
delete: async (endpoint) => {
try {
const response = await service.delete(endpoint);
return response;
} catch (error) {
throw new Error(`DELETE request failed: ${error.message}`);
}
}
}
@@ -88,6 +88,7 @@ export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向
thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤
query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词
queryShow: true,
retrievals: [ // 第一轮检索返回的资料列表
{
id: 'r1-1',
@@ -114,6 +115,7 @@ export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合',
query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词
// 本轮检索返回的资料列表
queryShow: true,
retrievals: [
{
id: 'r2-1',
@@ -144,6 +146,7 @@ export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答
thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项',
query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息
queryShow: true,
retrievals: [
{
id: 'r3-1',
@@ -181,9 +184,11 @@ export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
title: '正在准备检索',
thoughtMarkdown: '',
query: '',
queryShow: true,
queryLoading: true,
retrievals: [],
}],
thinkingComplete: false,
finalAnswerMarkdown: '',
});
@@ -196,7 +201,7 @@ export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
export const createComparePane = (mode: QAMode): ComparePane => ({
mode,
loading: true,
singleRagTrace: mode === 'single-round'
singleRagTrace: (mode === 'single-round' || mode === 'single-chat')
? {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '',
@@ -204,7 +209,7 @@ export const createComparePane = (mode: QAMode): ComparePane => ({
thinkingComplete: false,
}
: undefined,
thinkingTrace: mode === 'single-round' ? undefined : createMultiRoundStreamTrace(),
thinkingTrace: (mode === 'single-round' || mode === 'single-chat') ? undefined : createMultiRoundStreamTrace(),
sources: [],
});
@@ -215,88 +220,6 @@ export const createComparePane = (mode: QAMode): ComparePane => ({
* 拆分时完整保留原始 mock 数据,确保首屏体验不变。
*/
export const createMockSingleRagMessages = (): Message[] => ([
{
id: 'mock-user-code-preview',
role: 'user',
content: '帮我看一下 Markdown 代码块的展示效果',
timestamp: new Date(),
},
{
id: 'mock-assistant-code-preview',
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: {
title: '代码块渲染预览',
thinkingMarkdown: '这条模拟数据用于检查 MarkdownRenderer 的代码块样式:多行代码应该展示为带语言标签和复制按钮的彩色代码块,短单行代码例如 `const ok = true` 应该保持行内展示。',
answerMarkdown: [
'下面是几段模拟代码,用来预览不同语言的渲染效果。',
'',
'短单行代码:`const count = 1`,这一段应该显示为行内代码,而不是大代码块。',
'',
'### TSX 示例',
'',
'```tsx',
'type UserCardProps = {',
' name: string;',
' role: string;',
'};',
'',
'export function UserCard({ name, role }: UserCardProps) {',
' return (',
' <section className="rounded-lg border p-4">',
' <h3>{name}</h3>',
' <p>{role}</p>',
' </section>',
' );',
'}',
'```',
'',
'### Vue 示例',
'',
'```vue',
'<script setup lang="ts">',
'import { ref } from "vue";',
'',
'const keyword = ref("strategy");',
'</script>',
'',
'<template>',
' <button @click="keyword = keyword.toUpperCase()">',
' {{ keyword }}',
' </button>',
'</template>',
'```',
'',
'### Java 示例',
'',
'```java',
'public class StrategyScore {',
' public static int normalize(int value) {',
' return Math.max(0, Math.min(100, value));',
' }',
'',
' public static void main(String[] args) {',
' System.out.println(normalize(128));',
' }',
'}',
'```',
'',
'引用按钮也顺便保留测试:[[1]]',
].join('\n'),
thinkingComplete: true,
},
sources: [
{
title: 'MarkdownRenderer mock preview',
type: '本地模拟数据',
snippet: '用于预览 tsx、vue、java 代码块高亮,以及短单行代码的行内展示。',
source: 'GeneralQA.mock.ts',
time: '2026-04-30',
content: '这是一条本地模拟引用数据,用于测试 [[1]] 引用按钮点击后的详情展示。',
},
],
},
{
id: 'mock-user-1',
role: 'user',
@@ -382,111 +305,6 @@ export const createMockSingleRagMessages = (): Message[] => ([
},
],
},
{
id: 'mock-user-multi-1',
role: 'user',
content: '我想了解多轮检索的工作机制,以及它在大规模企业知识库中的实际表现如何?',
timestamp: new Date(),
},
{
id: 'mock-assistant-multi-1',
role: 'assistant',
content: '',
timestamp: new Date(),
thinkingTrace: createThinkingTrace('multi-round'),
sources: [
{
title: '多轮RAG检索白皮书',
type: '知识库',
snippet: '多轮检索通过逐轮改写查询、逐步聚焦,能在复杂问题上比单轮检索提升30%以上的召回覆盖。',
source: '内部架构组',
time: '2026-04-15',
content: '多轮检索的核心在于每轮检索后根据已召回内容改写查询,逐步缩小语义范围,最终实现精准覆盖。',
},
{
title: '企业知识库检索时延对比',
type: '技术报告',
snippet: '两轮检索在10万级文档库中平均增加40%延迟,但答案完整度提升显著。',
source: 'NLP团队',
time: '2026-03-22',
content: '实测数据:单轮检索平均时延1.2s,两轮检索平均时延1.7s,三轮检索平均时延2.3s。建议根据SLA选择合适轮次。',
},
],
},
{
id: 'mock-user-multi-2',
role: 'user',
content: '企业知识问答系统的多轮检索需要做哪些工程化优化?',
timestamp: new Date(),
},
{
id: 'mock-assistant-multi-2',
role: 'assistant',
content: '',
timestamp: new Date(),
thinkingTrace: {
title: '多轮检索工程化优化思考过程',
rounds: [
{
id: 'round-opt-1',
title: '分析工程化优化方向',
thoughtMarkdown: '用户关心的是工程层面的优化,需要从以下几个维度回答:\n\n1. 查询改写策略的缓存与复用\n2. 检索结果去重与增量更新\n3. 并发检索与超时控制\n4. 检索质量的在线评估机制',
query: '多轮检索 工程化优化 查询改写 缓存 去重 并发',
retrievals: [
{
id: 'ro1-1',
title: '查询改写缓存策略实践',
content: '对于相似查询,可通过向量相似度匹配复用上一轮的改写结果,减少调用大模型的次数和延迟。',
source: '知识库 / 查询优化实践指南.md',
},
{
id: 'ro1-2',
title: '检索结果去重方案',
content: '多轮检索容易出现重复文档,需基于文档ID或内容哈希进行去重,并在每轮检索时排除已召回文档。',
source: '知识库 / 检索去重技术方案.pdf',
},
],
},
{
id: 'round-opt-2',
title: '检索性能与质量保障',
thoughtMarkdown: '第二轮聚焦性能优化:\n\n1. 并发检索多个知识库以降低延迟\n2. 设置合理的检索超时防止长尾请求\n3. 引入在线相关性评分作为质量护栏',
query: '并发检索 超时控制 在线评估 知识库评分',
retrievals: [
{
id: 'ro2-1',
title: '知识库并发检索架构设计',
content: '采用异步并发查询多个索引,配合全局超时(建议3-5s),保证用户体验的同时最大化召回。',
source: '架构库 / 并发检索设计文档.docx',
},
{
id: 'ro2-2',
title: '检索质量在线评估方法',
content: '通过小模型对每轮召回结果做相关性打分,低于阈值时自动触发查询改写或切换检索策略。',
source: '评测库 / 在线质量评估方案.md',
},
{
id: 'ro2-3',
title: '多轮检索时延优化总结',
content: '工程实践中建议两轮为上限,超过时需评估能否合并为单轮。首批结果可先展示,第二轮做增量补充。',
source: '性能库 / 时延优化白皮书.pdf',
},
],
},
],
finalAnswerMarkdown: '多轮检索的工程化优化主要围绕四个方向:\n\n### 1. 查询改写优化\n- 缓存相似查询的改写结果,通过向量相似度匹配复用\n- 降低大模型调用频次,减少时延和成本\n\n### 2. 结果去重与增量更新\n- 基于文档ID或内容哈希去重\n- 每轮检索时排除已召回文档,避免重复返回\n- 增量模式:先返回首批结果,后续轮次做补充\n\n### 3. 并发与超时控制\n- 异步并发查询多个知识库索引\n- 设置全局超时(建议3-5秒),避免长尾请求\n- 首批结果到达后立即展示,后续增量追加\n\n### 4. 在线质量评估\n- 用小模型对召回结果做相关性打分\n- 低于阈值时自动触发查询改写或切换策略\n- 持续监控命中率和引用准确率\n\n> 建议:实际工程中两轮检索已能覆盖大多数复杂场景,超过两轮应重新评估是否可以通过更好的查询改写来合并。',
},
sources: [
{
title: '多轮检索工程化最佳实践',
type: '技术方案',
snippet: '两轮检索配合增量更新,可在延迟和完整度之间取得最优平衡。',
source: '工程团队',
time: '2026-05-01',
content: '总结了过去一年的多轮检索优化经验,涵盖查询改写缓存、结果去重、并发检索和在线评估四大方向。',
},
],
},
]);
/**
@@ -506,9 +324,10 @@ export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[]
const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : [];
return normalizedDocs.map((item, idx) => {
console.log(item,'检索资料')
if (typeof item === 'string') {
return {
title: `参考资料 ${idx + 1}`,
title: '',
type: '知识库',
snippet: item,
source: '',
@@ -529,6 +348,34 @@ export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[]
});
};
export const chatDataJSON = (chatJSON: string) => {
let chatJSONText = chatJSON.replace(/}{"/g, '},{"');
chatJSONText = '[' + chatJSONText + ']';
// console.log('解析chat',chatJSONText);
let chatData = [];
let chatTitle: any = '';
const docsList: any[] = [];
try {
chatData = JSON.parse(chatJSONText);
chatData.forEach((item: any) => {
if (item['answer']) {
chatTitle += item['answer'];
} else if (item['docs']) {
docsList.push(item);
}
});
return {
chatTitle,
docsList,
};
} catch (error) {
return {
chatTitle,
docsList,
};
}
};
/**
* 从流式文本中提取完整 JSON 对象。
*
@@ -582,3 +429,22 @@ export const extractJsonObjects = (raw: string): { objects: string[]; rest: stri
return { objects, rest: '' };
};
// 处理平台数据
export const platformTypeChange = (platform: string) => {
let platformType: string = '';
if (platform.includes('fb')) {
platformType = 'facebook';
} else if (platform.includes('x')) {
platformType = 'x';
} else if (platform.includes('in')) {
platformType = 'instagram';
} else if (platform.includes('re')) {
platformType = 'reddit';
} else if (platform.includes('th')) {
platformType = 'threads';
} else {
platformType = 'facebook';
}
return platformType;
}
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,7 @@
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
import { LucideIcon } from 'lucide-react';
export type QAMode = 'single-round' | 'multi-round' | 'deep-search';
export type QAMode = 'single-chat' | 'single-round' | 'multi-round' | 'deep-search' | 'hx-search' | 'news-report';
export type ViewMode = 'single' | 'compare';
export type KnowledgeScope = 'all' | 'specific-kb' | 'specific-domain';
export type AnswerStyle = 'concise' | 'detailed';
@@ -13,6 +14,8 @@ export interface SourceItem {
source?: string;
time?: string;
url?: string;
keywords?: string;
taskDirection?: string;
content?: string;
}
@@ -30,7 +33,14 @@ export interface ToolCallData {
tool_name: string;
tool_input: {
text?: string;
query?: string;
q?: string;
keyword?: string;
platform?: string;
user_fig?: string;
result?: string;
s?: string;
e?: string;
};
}
@@ -56,6 +66,8 @@ export interface ComparePane {
}
export interface CompareResult {
id: string | number;
compareUserInput: string;
left: ComparePane;
right: ComparePane;
differences?: {
@@ -72,3 +84,17 @@ export interface SearchProcessState {
hits: number;
queryRewrite: string;
}
export interface stateItem {
compareIndex: number;
compareId: string;
messageList: Message[],
compareResult: CompareResult[];
}
export interface qaModeItem {
value: QAMode;
label: string;
icon: LucideIcon;
desc: string;
}
@@ -1,5 +1,6 @@
import { BookOpen, Brain, ChevronLeft, ChevronRight, Sparkles } from 'lucide-react';
import type { AnswerStyle, CompareLayout, KnowledgeScope, QAMode, ViewMode } from './GeneralQA.types';
import React from 'react';
import { BookOpen, Bot, Brain, ChevronLeft, ChevronRight, Sparkles, User2Icon } from 'lucide-react';
import type { AnswerStyle, CompareLayout, KnowledgeScope, QAMode, qaModeItem, ViewMode } from './GeneralQA.types';
/**
* 左侧配置栏 Props。
@@ -21,6 +22,7 @@ type Props = {
autoSummarizeDiff: boolean; // 对比模式下是否自动总结左右差异
recommendBetter: boolean; // 对比模式下是否推荐更好的回答方案
isCollapsed: boolean; // 左侧配置面板是否折叠
qaModeOptions?: qaModeItem[];
setQaMode: (mode: QAMode) => void; // 设置单一模式的问答模式
setCompareLeftMode: (mode: QAMode) => void; // 设置对比模式左侧的模式
setCompareRightMode: (mode: QAMode) => void; // 设置对比模式右侧的模式
@@ -39,10 +41,13 @@ type Props = {
*
* 每个选项同时提供展示文案、图标和简短说明,便于配置栏用统一结构渲染卡片。
*/
const qaModeOptions = [
const qaModeList: qaModeItem[] = [
{ value: 'single-chat' as const, label: '快速', icon: Bot, desc: '快速直接' },
{ value: 'single-round' as const, label: '单轮RAG', icon: Sparkles, desc: '快速直接' },
{ value: 'multi-round' as const, label: '多轮RAG', icon: Brain, desc: '深入理解' },
{ value: 'deep-search' as const, label: '深度检索', icon: BookOpen, desc: '全面分析' },
{ value: 'deep-search' as const, label: '任务检索', icon: BookOpen, desc: '检索相关任务' },
{ value: 'hx-search' as const, label: '画像检索', icon: User2Icon, desc: '检索相关账号' },
{ value: 'news-report' as const, label: '新闻报告生成', icon: User2Icon, desc: '检索相关新闻' },
];
/**
@@ -77,6 +82,7 @@ export default function GeneralQAConfigSidebar({
autoSummarizeDiff,
recommendBetter,
isCollapsed,
qaModeOptions = qaModeList,
setQaMode,
setCompareLeftMode,
setCompareRightMode,
@@ -88,7 +94,7 @@ export default function GeneralQAConfigSidebar({
setAutoSummarizeDiff,
setRecommendBetter,
setIsCollapsed,
}: Props): JSX.Element {
}: Props): React.JSX.Element {
/**
* 自定义勾选图标。
*
@@ -130,7 +136,9 @@ export default function GeneralQAConfigSidebar({
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>问答模式</h3>
<div className="space-y-2">
{qaModeOptions.map((mode) => (
{qaModeOptions.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.value}
onClick={() => setQaMode(mode.value)}
@@ -144,13 +152,14 @@ export default function GeneralQAConfigSidebar({
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<mode.icon className="w-5 h-5" />
<Icon className="w-5 h-5" />
<div className="flex-1">
<div className="text-sm font-medium">{mode.label}</div>
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{mode.desc}</div>
</div>
</button>
))}
)
})}
</div>
</div>
) : (
@@ -160,18 +169,22 @@ export default function GeneralQAConfigSidebar({
<div className="mb-4">
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式A</label>
<select value={compareLeftMode} onChange={(e) => setCompareLeftMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
<option value="single-round">单轮RAG</option>
<option value="multi-round">多轮RAG</option>
<option value="deep-search">深度检索</option>
{
qaModeOptions.filter(item => item.value !== compareRightMode).map(item => (
<option value={item.value} key={item.value}>{item.label}</option>
))
}
</select>
</div>
<div className={`text-center my-2 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}><span className="text-lg">VS</span></div>
<div className="mb-4">
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式B</label>
<select value={compareRightMode} onChange={(e) => setCompareRightMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
<option value="single-round">单轮RAG</option>
<option value="multi-round">多轮RAG</option>
<option value="deep-search">深度检索</option>
{
qaModeOptions.filter(item => item.value !== compareLeftMode).map(item => (
<option value={item.value} key={item.value}>{item.label}</option>
))
}
</select>
</div>
</div>
@@ -189,7 +202,7 @@ export default function GeneralQAConfigSidebar({
</>
)}
<div className="mb-6">
{/* <div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>知识范围</h3>
<div className="space-y-2">
{knowledgeScopeOptions.map((option) => (
@@ -202,9 +215,9 @@ export default function GeneralQAConfigSidebar({
</label>
))}
</div>
</div>
</div> */}
<div className="mb-6">
{/* <div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>回答偏好</h3>
<div className="space-y-3">
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
@@ -223,9 +236,9 @@ export default function GeneralQAConfigSidebar({
<span className="text-sm">展示检索过程</span>
</label>
</div>
</div>
</div> */}
{viewMode === 'compare' && (
{/* {viewMode === 'compare' && (
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比设置</h3>
<div className="space-y-3">
@@ -241,7 +254,7 @@ export default function GeneralQAConfigSidebar({
</label>
</div>
</div>
)}
)} */}
</>
)}
</div>
@@ -0,0 +1,87 @@
import React from 'react';
import { History } from 'lucide-react';
import type { ViewMode } from './GeneralQA.types';
/**
* 顶部栏 Props。
*
* `setViewMode` 由父组件传入,父组件会在生成中切换时做确认拦截;
* Header 本身不直接感知 loading,保持纯展示和事件转发。
*/
type Props = {
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
chatHeadTitle?: string;
chatDesign?: string;
compareShow?: boolean;
setViewMode: (mode: ViewMode) => void; // 设置视图模式回调函数
onNavigate?: (page: string) => void; // 页面导航回调函数
};
/**
* 通用问答页面顶部栏。
*
* 该组件只负责页面标题、模式切换和历史记录入口,所有业务状态仍由父组件维护。
* 这样拆分后,顶部导航的样式和交互不会和中间问答流处理逻辑混在一起,后续如果要新增
* "保存模板""导出记录"等入口,也可以只改这个文件。
*/
export default function GeneralQAHeader({ isDark, viewMode, chatHeadTitle = '通用问答', chatDesign = '支持单轮RAG、多轮RAG、深度检索及结果对比', compareShow = true, setViewMode, onNavigate }: Props): React.JSX.Element {
return (
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{chatHeadTitle}
</h1>
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{chatDesign}
</span>
</div>
{
compareShow && <div className="flex items-center gap-2">
<div className={`flex rounded-lg p-1 ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
<button
onClick={() => setViewMode('single')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'single'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
单一模式
</button>
<button
onClick={() => setViewMode('compare')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'compare'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
对比模式
</button>
</div>
{onNavigate && (
<button
onClick={() => onNavigate('qa-records')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
)}
</div>
}
</div>
);
}
@@ -0,0 +1,933 @@
import React, { useEffect, useState } from 'react';
import { Dialog, Textarea } from 'tdesign-react';
import {
Bookmark,
BrainCircuit,
Bot,
ChevronDown,
FileSearch,
Loader2,
RotateCcw,
Search,
Send,
Settings,
Sparkles,
Square,
Copy,
Trash2,
Edit3,
Upload,
UserRound,
ExternalLink,
} from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
import MarkdownRenderer from '@/strategy-components/MarkdownRenderer';
import { SourceItem } from './GeneralQA.types';
import '../qa/qa-animations.css';
/**
* 单条检索结果。
*
* 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。
*/
export interface QARetrievalItem {
id?: string;
title: string;
content: string;
keywords?: string;
taskDirection?: string;
type?: string;
time?: string;
source?: string;
recUuid?: string;
platform?: string;
fans?: number;
comment_count?: number;
message_id?: string;
hot_num?: number;
}
/**
* 多轮检索中的单轮思考节点。
*
* `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。
*/
export interface QAThinkingRound {
id: string; // 唯一标识符,用于列表渲染时的 key
title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集"
thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑
queryTitle?: string; // 检索内容
query: string; // 该轮发送给检索系统的查询语句
queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画
textidContent?: string; // 新闻详细内容
retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表
queryShow: boolean; // 检索列表为空时是否显示
}
/**
* 多轮/深度检索的完整思考链。
*
* rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。
*/
export interface QAThinkingTrace {
title?: string; // 整体思考链的标题
rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果
finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
}
/**
* 单轮 RAG 的回答结构。
*
* 后端可能把 `<think>...</think>` 和答案混在同一个 answer 流中,解析后分别放到 thinkingMarkdown 与 answerMarkdown。
*/
export interface QASingleRagTrace {
title?: string; // 思考过程的标题,如"单轮 RAG 思考过程"
thinkingMarkdown: string; // 模型在生成回答前的思考过程内容,使用 Markdown 格式
answerMarkdown: string; // 最终生成的回答内容,使用 Markdown 格式
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
}
export interface QACommonMessage {
id: string; // 消息唯一标识符
role: 'user' | 'assistant'; // 消息发送者角色:user 为用户消息,assistant 为 AI 回复
content: string; // 消息的纯文本内容(当使用 thinkingTrace 或 singleRagTrace 时为空)
timestamp?: Date; // 消息发送时间
thinkingTrace?: QAThinkingTrace; // 多轮/深度检索模式的思考链,包含多轮检索过程和最终回答
sources?: { // 消息引用的参考资料列表
title: string; // 参考资料标题
type: string; // 资料类型,如"文档"、"网页"等
snippet: string; // 资料摘要内容
source?: string; // 来源名称或 URL
time?: string; // 发布时间
url?: string; // 资料链接
content?: string; // 完整内容(可能用于详情弹窗)
}[];
}
export interface QACommonMessage {
singleRagTrace?: QASingleRagTrace; // 单轮 RAG 模式的思考过程和回答结构
}
interface QAAvatarProps {
role: 'user' | 'assistant';
isActive?: boolean;
label?: string;
variant?: 'default' | 'blue' | 'purple';
}
const getAssistantAvatarClass = (variant: QAAvatarProps['variant'], isDark: boolean) => {
switch (variant) {
case 'blue':
return isDark
? 'bg-gradient-to-br from-blue-400 via-blue-600 to-cyan-700 text-white'
: 'bg-gradient-to-br from-blue-300 via-blue-500 to-cyan-500 text-white';
case 'purple':
return isDark
? 'bg-gradient-to-br from-purple-400 via-violet-600 to-fuchsia-700 text-white'
: 'bg-gradient-to-br from-purple-300 via-violet-500 to-fuchsia-500 text-white';
default:
return isDark
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white';
}
};
/**
* 统一头像组件。
*
* 单一模式使用默认助手色;对比模式可通过 variant 指定蓝/紫色系,让头像与左右面板标签保持一致。
* `isActive` 会触发呼吸和星光动画,用来提示当前助手正在生成。
*/
export const QAAvatar: React.FC<QAAvatarProps> = ({ role, isActive = false, label, variant = 'default' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 overflow-hidden shadow-sm ${role === 'assistant'
? getAssistantAvatarClass(variant, isDark)
: isDark
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
} ${isActive ? 'qa-avatar-active' : ''}`}
title={label}
>
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
<Icon className={`w-5 h-5 relative z-10 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
{role === 'assistant' && isActive && (
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200 qa-avatar-spark" />
)}
</div>
);
};
interface TypingIndicatorProps {
label?: string;
assistantLabel?: string;
}
/**
* 助手预连接/生成中的占位提示。
*
* 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。
*/
export const TypingIndicator: React.FC<TypingIndicatorProps> = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex gap-3">
<QAAvatar role="assistant" isActive label={assistantLabel} />
<div className={`max-w-[70%] rounded-2xl px-4 py-3 shadow-sm border ${isDark ? 'bg-slate-700/60 border-slate-600/50 text-slate-200' : 'bg-white border-red-100 text-gray-700'
}`}>
<div className="flex items-center gap-3">
<Loader2 className={`w-4 h-4 animate-spin ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span
className={`text-sm font-medium qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'}`}
>
{label}
</span>
{/* <div className="flex gap-1">
{[0, 1, 2].map((idx) => (
<span
key={idx}
className={`w-1.5 h-1.5 rounded-full qa-typing-dot ${isDark ? 'bg-blue-300' : 'bg-red-400'}`}
style={{
animationDelay: `${idx * 160}ms`,
}}
/>
))}
</div> */}
</div>
</div>
</div>
);
};
interface ChatMessageProps<T extends QACommonMessage> {
message: T;
showSources?: boolean;
assistantLabel?: string;
assistantActive?: boolean;
onCitationClick?: (message: T, citationIndex: number) => void;
onCopyAssistant?: (message: T) => void;
onDeleteAssistant?: (message: T) => void;
onEditAssistant?: (message: T) => void;
onResendAssistant?: (message: T) => void;
renderAssistantExtra?: (message: T) => React.ReactNode;
}
/**
* 聊天消息渲染器。
*
* 根据消息类型自动选择普通文本、单轮 RAG 回答或多轮检索思考链。
* sources 区域独立折叠展示,避免引用资料过长时挤占主回答阅读空间。
*/
export function ChatMessage<T extends QACommonMessage>({
message,
showSources = true,
assistantLabel = 'AI 助手',
assistantActive = false,
onCitationClick,
onCopyAssistant,
onDeleteAssistant,
onEditAssistant,
onResendAssistant,
renderAssistantExtra,
}: ChatMessageProps<T>) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isUser = message.role === 'user';
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL;
const [visible, setVisible] = useState(false); // 打开详情对话框
const [listData, setListData] = useState<SourceItem>({
title: '',
type: '',
time: '',
snippet: '',
source: '',
content: '',
});
const showDialog = (item: SourceItem) => {
console.log('item', item)
if (item.url) {
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`);
} else {
setListData(item);
setVisible(true);
}
};
return (
<div className="space-y-3">
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
{!isUser && <QAAvatar role="assistant" isActive={assistantActive} label={assistantLabel} />}
<div className={`${hasTrace ? 'max-w-[85%] flex-1' : 'max-w-[72%]'} rounded-2xl px-4 py-3 shadow-sm ${isUser
? isDark
? 'bg-blue-600/70 text-white'
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
: message.singleRagTrace || message.thinkingTrace
? isDark
? 'bg-slate-800/40 text-slate-100 border border-slate-600/40'
: 'bg-white text-gray-800 border border-transparent shadow-[0px_3px_12px_rgba(0, 0, 0, 0.12)] px-0 py-0'
: isDark
? 'bg-slate-700/60 text-slate-100 border border-slate-600/40'
: 'bg-white text-gray-800 border border-gray-100'
}`}>
{message.singleRagTrace && !isUser ? (
<SingleRagAnswer
trace={message.singleRagTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : message.thinkingTrace && !isUser ? (
<RetrievalThinkingTrace
trace={message.thinkingTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : (
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
<div className={`mt-2 flex items-center justify-end gap-1.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{onCopyAssistant && (
<button type="button" onClick={() => onCopyAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-slate-100' : 'hover:bg-gray-100 hover:text-gray-700'}`} title="复制回答">
<Copy className="w-3.5 h-3.5" />
</button>
)}
{onDeleteAssistant && (
<button type="button" onClick={() => onDeleteAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-rose-300' : 'hover:bg-red-50 hover:text-red-600'}`} title="删除问答">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{onEditAssistant && (
<button type="button" onClick={() => onEditAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-blue-300' : 'hover:bg-blue-50 hover:text-blue-600'}`} title="编辑回答和思考过程">
<Edit3 className="w-3.5 h-3.5" />
</button>
)}
{onResendAssistant && (
<button type="button" onClick={() => onResendAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-emerald-300' : 'hover:bg-emerald-50 hover:text-emerald-600'}`} title="重新发送">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>
{isUser && <QAAvatar role="user" label="用户" />}
</div>
{!isUser && message.sources && message.sources.length > 0 && showSources && (
<CollapsibleSection
defaultOpen={false}
className={`ml-12 ${hasTrace ? 'max-w-[85%]' : 'max-w-[72%]'} ${isDark ? 'bg-slate-800/50 border-slate-700/50' : 'bg-gray-50 border-gray-200'}`}
title={(
<div className="flex items-center gap-2">
<BrainCircuit className={`w-3.5 h-3.5 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-xs font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>引用来源</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>({message.sources.length})</span>
</div>
)}
>
<div className="space-y-2 pt-[10px]">
{message.sources.map((source, idx) => (
<div key={idx} className={`text-xs p-2 rounded-[4px] border shadow-sm transition-all duration-200 ease-out cursor-pointer hover:-translate-y-0.5 hover:scale-[1.01] ${isDark ? 'bg-slate-700/50 border-slate-600/50 shadow-black/10 hover:bg-slate-700/80 hover:border-blue-400/40 hover:shadow-lg hover:shadow-blue-950/20' : 'bg-white border-gray-100 shadow-gray-200/60 hover:border-red-200 hover:shadow-md hover:shadow-red-100/70'}`} onClick={() => showDialog(source)}>
<div className="flex items-center gap-2 mb-1 min-w-0">
<BrainCircuit className={`w-3 h-3 shrink-0 ${isDark ? 'text-blue-400' : 'text-red-400'}`} />
<span className={`font-medium truncate min-w-0 flex-1 ${isDark ? 'text-slate-300' : 'text-gray-700'}`} title={source.title}>{source.title}</span>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-200 text-gray-500'}`}>
{source.type}
</span>
</div>
<p className={`line-clamp-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{source.snippet}</p>
</div>
))}
</div>
</CollapsibleSection>
)}
<Dialog
visible={visible}
onClose={() => setVisible(false)}
header="提示"
footer={null}
>
<div>
<div className="pr-9">
<h3 className="text-base font-semibold">{listData.title || '引用内容'}</h3>
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<div>来源:{listData.source || '未知来源'}</div>
<div>时间:{listData.time || '未知'}</div>
</div>
</div>
{listData.url && (
<div className="mt-4">
<a
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${listData.url}`}
target="_blank"
rel="noreferrer"
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
title="打开原始链接"
>
<ExternalLink className="w-4 h-4" />
查看原文链接
</a>
</div>
)}
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
}`}>
{listData.content || '暂无引用内容'}
</div>
</div>
</Dialog>
{!isUser && renderAssistantExtra?.(message)}
</div>
);
}
interface CollapsibleSectionProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
headerClassName?: string;
}
/**
* 通用折叠面板。
*
* 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。
*/
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
defaultOpen = true,
className = '',
headerClassName = '',
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = useState(defaultOpen);
// 当 defaultOpen 改变时,更新 open 状态
useEffect(() => {
setOpen(defaultOpen);
}, [defaultOpen]);
return (
<div className={`rounded-2xl border overflow-hidden ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
} ${headerClassName}`}
>
<div className="flex-1 min-w-0">{title}</div>
<ChevronDown
className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
/>
</button>
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
<div className="overflow-hidden">
<div className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
{children}
</div>
</div>
</div>
</div>
);
};
interface SingleRagAnswerProps {
trace: QASingleRagTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 单轮 RAG 回答展示。
*
* 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。
* 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。
*/
export const SingleRagAnswer: React.FC<SingleRagAnswerProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
defaultOpen={!trace.thinkingComplete}
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
}`}>
<Sparkles className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>{trace.title || '思考过程'}</div>
{trace.thinkingComplete ? (
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考已完成,可展开查看
</div>
) : (
<div className={`text-xs qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'}`}>
正在接收 &lt;think&gt; 流式思考内容
</div>
)}
</div>
</div>
}
>
{trace.thinkingMarkdown ? (
<MarkdownRenderer
content={trace.thinkingMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
等待模型输出思考内容...
</div>
)}
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
{trace.answerMarkdown ? (
<MarkdownRenderer
content={trace.answerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
{trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
</div>
)}
</div>
</div>
);
};
interface RetrievalListProps {
items: QARetrievalItem[];
}
/**
* 检索结果列表。
*
* 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。
*/
const RetrievalList: React.FC<RetrievalListProps> = ({ items }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [visible, setVisible] = useState(false); // 打开详情对话框
const [listData, setListData] = useState<QARetrievalItem>({
title: '',
content: '',
});
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL;
const b1SearchUrl = import.meta.env.VITE_AIPI_B1COH_URL;
const b2SearchUrl = import.meta.env.VITE_AIPI_B2ACTOR_URL;
const showDialog = (item: QARetrievalItem) => {
console.log('item', item)
if (item.type === 'more') {
if (item.recUuid) {
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.recUuid}`);
} else {
setListData(item);
setVisible(true);
}
} else if (item.type === 'task') {
window.open(`${b1SearchUrl}/#/task/item-aicoh/step-info-search?id=${item.id}`)
} else if (item.type === 'hx') {
window.open(`${b2SearchUrl}/fromB/toDetail?user_id=${item.id}&platform=${item.platform}`);
} else {
setListData(item);
setVisible(true);
}
};
return (
<div className="space-y-2">
{items.map((item, index) => (
<div key={index}>
{
item.type !== 'user_behavior' ? <CollapsibleSection
key={index}
defaultOpen={index === 0}
className={isDark ? 'bg-slate-800/40' : 'bg-gray-50/70'}
title={
<div className="flex items-center gap-2">
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-semibold ${isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-red-100 text-red-600'
}`}>{index + 1}</span>
<div className="min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{item.title}</div>
{
item.type === 'more' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>时间:{item.time || '-'}</div>
}
{
item.type === 'task' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>关键词:{item.keywords || '-'}</div>
}
{
item.type === 'hx' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span><span className="ml-[10px]">粉丝数:{item.fans || '-'}</span></div>
}
{
(item.type === 'taiwan_news' || item.type === 'world_news' || item.type === 'messaage_comment' || item.type === 'america_news') && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span>{item.comment_count && <span className="ml-[10px]">评论数:{item.comment_count || '-'}</span>}</div>
}
{
item.type === 'get_time_data' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>时间:{item.time || '-'}</span><span className="ml-[10px]">热度:{item.hot_num || '-'}</span></div>
}
</div>
</div>
}
>
<p className={`text-sm leading-relaxed cursor-progress line-clamp-3 hover:underline ${isDark ? 'text-slate-300' : 'text-gray-600'}`} onClick={() => showDialog(item)}>{item.content}</p>
</CollapsibleSection>
: <div className="rounded-[5px] border border-[#f5f5f5] p-[10px]" key={index + 10}>
<span className="font-bold mr-[10px]">{index + 1}.</span>
<span>{item.title}</span>
</div>
}
</div>
))}
<Dialog
visible={visible}
onClose={() => setVisible(false)}
header="提示"
footer={null}
>
<div>
<div className="pr-9">
<h3 className="text-base font-semibold">{listData.title}</h3>
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{listData.time && <span>时间:{listData.time || '未知'}</span>}
{listData.platform && <span className="ml-[10px]">平台:{listData.platform || '未知'}</span>}
{listData.comment_count && <span className="ml-[10px]">评论数:{listData.comment_count || '未知'}</span>}
</div>
</div>
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
}`}>
{listData.content || '暂无引用内容'}
</div>
</div>
</Dialog>
</div>
);
};
interface SearchQueryBadgeProps {
query: string;
loading?: boolean;
queryTitle?: string;
}
/**
* 检索词徽标。
*
* 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。
*/
const SearchQueryBadge: React.FC<SearchQueryBadgeProps> = ({ query, loading = false, queryTitle = '检索词' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5 border ${isDark ? 'bg-blue-500/10 border-blue-400/20 text-blue-200' : 'bg-red-50 border-red-100 text-red-600'
}`}>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
<span className="text-xs flex-shrink-0">{queryTitle}</span>
<span className={`text-xs font-medium truncate ${loading && 'qa-generating-marquee'} ${loading && (isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light')}`}>
{query}
</span>
</div>
);
};
interface RetrievalThinkingTraceProps {
trace: QAThinkingTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 多轮/深度检索思考链展示。
*
* 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。
* 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。
*/
export const RetrievalThinkingTrace: React.FC<RetrievalThinkingTraceProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
defaultOpen={!trace.thinkingComplete}
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
}`}>
<BrainCircuit className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{trace.title || '思考过程'}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
共 {trace.rounds.length} 轮推理与检索,可展开查看细节
</div>
</div>
</div>
}
>
<div className="space-y-3 pt-1">
{trace.rounds.map((round, index) => (
<div key={index} className={`relative pl-5 ${index < trace.rounds.length - 1 ? 'pb-2' : ''}`}>
{index < trace.rounds.length - 1 && (
<div className={`absolute left-[7px] top-8 bottom-0 w-px ${isDark ? 'bg-slate-700' : 'bg-red-100'}`} />
)}
<div className={`absolute left-0 top-4 w-3.5 h-3.5 rounded-full border-2 ${isDark ? 'bg-slate-900 border-cyan-400' : 'bg-white border-red-400'
}`} />
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-900/40' : 'bg-red-50/30'}
title={
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-100 text-red-600'
}`}>第 {index + 1} 轮</span>
<span className={`text-sm font-medium ${round.queryLoading && 'qa-generating-marquee'} ${round.queryLoading && (isDark ? 'text-slate-100 qa-generating-marquee-dark' : 'text-gray-900 qa-generating-marquee-light')}`}>{round.title}</span>
</div>
}
>
<div className="space-y-3">
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<Sparkles className={`w-4 h-4 ${isDark ? 'text-violet-300' : 'text-orange-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>思考内容</span>
</div>
}
>
<MarkdownRenderer
content={round.thoughtMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</CollapsibleSection>
{
round.queryShow &&
<div className="flex items-center">
<SearchQueryBadge query={round.query} queryTitle={round.queryTitle} loading={round.queryLoading} />
</div>
}
{
round.queryShow &&
<CollapsibleSection
defaultOpen={!round.queryLoading}
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<FileSearch className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>检索内容</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{round.retrievals.length} 条</span>
</div>
}
>
{round.queryLoading ? (
<div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
正在召回相关内容...
</div>
) : (
<RetrievalList items={round.retrievals} />
)}
</CollapsibleSection>
}
{
round.textidContent &&
<div className="mt-[10px]">
{round.textidContent}
</div>
}
</div>
</CollapsibleSection>
</div>
))}
</div>
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>最终回答</div>
{
!trace.finalAnswerMarkdown && <div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
正在生成思考过程与检索内容...
</div>
}
<MarkdownRenderer
content={trace.finalAnswerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</div>
</div>
);
};
interface EmptyChatStateProps {
icon?: React.ReactNode;
title: string;
description: string;
}
/**
* 空状态展示。
*
* 当当前模式没有任何消息时使用,提示用户可以开始输入问题。
*/
export const EmptyChatState: React.FC<EmptyChatStateProps> = ({ icon, title, description }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${isDark ? 'bg-slate-700/40' : 'bg-gradient-to-br from-red-50 to-orange-50'
}`}>
{icon ?? <Bot className="w-11 h-11 opacity-60" />}
</div>
<p className="text-lg font-medium">{title}</p>
<p className="text-sm mt-2">{description}</p>
</div>
);
};
interface QAComposerProps {
value: string;
placeholder: string;
isLoading: boolean;
canSubmit?: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
onKeyDown?: (val: string) => void;
onUpload?: () => void;
onReset?: () => void;
onSave?: () => void;
onCollect?: () => void;
onOpenSettings?: () => void;
showUpload?: boolean;
showReset?: boolean;
showSave?: boolean;
showCollect?: boolean;
}
/**
* 问答输入区。
*
* 负责输入、发送/停止、附件、重置、保存、收藏和模型参数设置入口。
* 具体业务动作通过 props 回调交给父组件处理,保证输入区本身不依赖某个问答模式。
*/
export const QAComposer: React.FC<QAComposerProps> = ({
value,
placeholder,
isLoading,
canSubmit,
onChange,
onSubmit,
onStop,
onKeyDown,
onUpload,
onReset,
onSave,
onCollect,
onOpenSettings,
showUpload = true,
showReset = false,
showSave = true,
showCollect = true,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const submitEnabled = canSubmit ?? Boolean(value.trim());
const iconButtonClass = `p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-600 text-slate-400' : 'hover:bg-gray-200 text-gray-500'}`;
const handleKeyDown = (val: string, context: { e: React.KeyboardEvent }) => {
if (context?.e?.key === 'Enter') {
context.e.preventDefault();
onKeyDown?.(val);
}
};
return (
<div className={`border-t p-4 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
<Textarea
value={value}
onChange={(nextValue) => onChange(String(nextValue))}
onKeydown={handleKeyDown}
placeholder={placeholder}
autosize={{ minRows: 1, maxRows: 15 }}
disabled={isLoading}
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${isDark
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
}`}
/>
<div className="flex items-center gap-2">
{/* {showUpload && (
<button onClick={onUpload} className={iconButtonClass} title="上传附件" type="button">
<Upload className="w-5 h-5" />
</button>
)}
{showReset && (
<button onClick={onReset} className={iconButtonClass} title="重新提问" type="button">
<RotateCcw className="w-5 h-5" />
</button>
)}
{showSave && (
<button onClick={onSave} className={iconButtonClass} title="保存记录" type="button">
<Bookmark className="w-5 h-5" />
</button>
)}
{showCollect && (
<button onClick={onCollect} className={iconButtonClass} title="加入收藏" type="button">
<Bookmark className="w-5 h-5" />
</button>
)} */}
{onOpenSettings && (
<button onClick={onOpenSettings} className={iconButtonClass} title="模型参数设置" type="button">
<Settings className="w-5 h-5" />
</button>
)}
<button
onClick={isLoading ? onStop : onSubmit}
disabled={!isLoading && !submitEnabled}
className={`px-4 py-2 rounded-xl font-medium transition-all flex items-center gap-2 shadow-sm ${isLoading
? isDark
? 'bg-rose-600 text-white qa-stop-breathing'
: 'bg-red-600 text-white qa-stop-breathing'
: submitEnabled
? isDark
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
: isDark
? 'bg-slate-600 text-slate-400 cursor-not-allowed'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
type="button"
>
{isLoading ? <Square className="w-4 h-4 fill-current" /> : <Send className="w-4 h-4" />}
{isLoading ? '停止' : '发送'}
</button>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large. Load diff
@@ -1,80 +0,0 @@
import { History } from 'lucide-react';
import type { ViewMode } from './GeneralQA.types';
/**
* 顶部栏 Props。
*
* `setViewMode` 由父组件传入,父组件会在生成中切换时做确认拦截;
* Header 本身不直接感知 loading,保持纯展示和事件转发。
*/
type Props = {
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
setViewMode: (mode: ViewMode) => void; // 设置视图模式回调函数
};
/**
* 通用问答页面顶部栏。
*
* 该组件只负责页面标题、模式切换和历史记录入口,所有业务状态仍由父组件维护。
* 这样拆分后,顶部导航的样式和交互不会和中间问答流处理逻辑混在一起,后续如果要新增
* “保存模板”“导出记录”等入口,也可以只改这个文件。
*/
export default function GeneralQAHeader({ isDark, viewMode, setViewMode }: Props): JSX.Element {
return (
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
通用问答
</h1>
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
支持单轮RAG、多轮RAG、深度检索及结果对比
</span>
</div>
<div className="flex items-center gap-2">
<div className={`flex rounded-lg p-1 ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
<button
onClick={() => setViewMode('single')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
viewMode === 'single'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
单一模式
</button>
<button
onClick={() => setViewMode('compare')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
viewMode === 'compare'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
对比模式
</button>
</div>
<button
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
</div>
</div>
);
}
@@ -21,6 +21,7 @@ import {
} from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import MarkdownRenderer from '../../MarkdownRenderer';
import './qa-animations.css';
/**
* 单条检索结果。
@@ -0,0 +1,171 @@
/**
* QA 系列共享动画与样式
*
* 这些类被 QACommon.tsx(以及通过它的 GeneralQA / AgentQA / CollaborativeQA)使用。
* 全部样式从 web-code/theme.css、web-code/App.css 和 web-code/index.css 迁移而来,
* 仅保留 QA 系列实际使用的部分。
*/
/* ── 头像扫光(生成中沿头像横扫的高光条) ───────────────────────────── */
@keyframes qa-avatar-shine {
0% {
transform: translateX(-120%) skewX(-18deg);
opacity: 0;
}
45% {
opacity: 0.5;
}
100% {
transform: translateX(120%) skewX(-18deg);
opacity: 0;
}
}
/* ── 头像整体微旋转(generating 状态) ─────────────────────────────── */
@keyframes qa-avatar-orbit {
0%,
100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(4deg) scale(1.06);
}
}
/* ── 头像图标脉冲 ────────────────────────────────────────────────── */
@keyframes qa-avatar-icon-pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.12);
opacity: 0.82;
}
}
/* ── 右上角 Sparkles 闪烁 ───────────────────────────────────────── */
@keyframes qa-spark-twinkle {
0%,
100% {
transform: scale(0.85) rotate(0deg);
opacity: 0.65;
}
50% {
transform: scale(1.15) rotate(18deg);
opacity: 1;
}
}
/* ── 停止按钮呼吸 ─────────────────────────────────────────────── */
@keyframes qa-stop-breathing {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.45);
}
50% {
transform: scale(1.04);
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
}
}
/* ── “正在生成” 跑马灯(文字渐变流动) ─────────────────────────── */
@keyframes qa-generating-marquee {
0% {
background-position: 180% 50%;
}
100% {
background-position: -80% 50%;
}
}
/* ── 输入中三个圆点跳动 ───────────────────────────────────────── */
@keyframes qa-typing-dot {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-5px);
opacity: 1;
}
}
/* ── 类绑定 ───────────────────────────────────────────────────── */
.qa-avatar-active {
animation: qa-avatar-orbit 1.8s ease-in-out infinite;
}
.qa-avatar-icon-active {
animation: qa-avatar-icon-pulse 1.1s ease-in-out infinite;
}
.qa-avatar-active .qa-avatar-shine {
animation: qa-avatar-shine 1.6s ease-in-out infinite;
}
.qa-avatar-spark {
animation: qa-spark-twinkle 1s ease-in-out infinite;
}
.qa-stop-breathing {
animation: qa-stop-breathing 1.25s ease-in-out infinite;
}
.qa-typing-dot {
animation: qa-typing-dot 1.2s ease-in-out infinite;
}
.qa-generating-marquee {
display: inline-block;
background-size: 220% 100%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
animation: qa-generating-marquee 1.45s linear infinite;
}
.qa-generating-marquee-light {
background-image: linear-gradient(
90deg,
#4b5563 0%,
#4b5563 34%,
#ef4444 48%,
#fb923c 55%,
#4b5563 68%,
#4b5563 100%
);
}
.qa-generating-marquee-dark {
background-image: linear-gradient(
90deg,
#cbd5e1 0%,
#cbd5e1 34%,
#67e8f9 48%,
#a78bfa 55%,
#cbd5e1 68%,
#cbd5e1 100%
);
}
/* ── 兜底:清掉 TDesign textarea 的边框/阴影/背景 ─────────────── */
.qa-composer-textarea,
.qa-composer-textarea .t-textarea,
.qa-composer-textarea .t-textarea__inner {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
outline: none !important;
}
.qa-composer-textarea .t-textarea__inner {
resize: none !important;
min-height: 40px !important;
padding: 0 !important;
}
@@ -0,0 +1,50 @@
import React, { useEffect, useState } from "react";
import {
getKnowledgeIframeApiUrl,
getKnowledgeIframeSkin,
} from "@/core/strategy/external-links";
import { requestCrudService } from "@/service/request-axios-service";
import "@/strategy-components/components/task-components/css/strategy-data.css";
const service = requestCrudService;
const KnowledgeBaseResourceManagement: React.FC = () => {
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
const initIframeUrl = async () => {
const apiUrl = getKnowledgeIframeApiUrl();
if (!apiUrl) return;
const jsonStr = localStorage.getItem("userInfo");
if (!jsonStr) return;
try {
const userInfo = JSON.parse(jsonStr);
const token = userInfo.accessToken;
if (!token) return;
const res = (await service.post(apiUrl, {
token,
skin: getKnowledgeIframeSkin(),
})) as { iframeUrl?: string };
if (res?.iframeUrl) {
setIframeUrl(res.iframeUrl);
}
} catch (error) {
console.error("Failed to load knowledge iframe url:", error);
}
};
void initIframeUrl();
}, []);
return (
<div className="strategy-data iframe-container">
<iframe src={iframeUrl} className="full-size-iframe" title="知识库资源管理" />
</div>
);
};
export default KnowledgeBaseResourceManagement;
@@ -0,0 +1,20 @@
import React, { useEffect, useState } from "react";
import { getNetworkActorIframeUrl } from "@/core/strategy/external-links";
import "@/strategy-components/components/task-components/css/strategy-data.css";
const NetworkActorResourceManagement: React.FC = () => {
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
setIframeUrl(getNetworkActorIframeUrl());
}, []);
return (
<div className="strategy-data iframe-container">
<iframe src={iframeUrl} className="full-size-iframe" title="网络xwt资源管理" />
</div>
);
};
export default NetworkActorResourceManagement;
@@ -0,0 +1,207 @@
import { useTheme } from '@/contexts/ThemeContext';
import { actionColumnItem, ActionListItem, TaskListItem, taskStatusList } from './TaskManagement';
import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, Search, User, Users, Workflow } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Col, Empty, Loading, Pagination, Progress, Row } from 'tdesign-react';
import { requestCrudService } from '@/service/request-axios-service';
import { ActionColumn } from './ActionColumn';
interface ActionAnalyzesProps {
TaskType?: string;
title: string;
category: string;
subCategory: string;
}
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category, subCategory}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState<string>('');
const [taskList, setTaskList] = useState<TaskListItem[]>([]); // 任务列表
const [taskId, setTaskId] = useState<number>(0); // 任务
const [actionColumnList, setActionColumnList] = useState<actionColumnItem[]>([]);
const [taskLoading, setTaskLoading] = useState<boolean>(false);
const [actionLoading, setActionLoading] = useState<boolean>(false);
// 当前页码,默认为第 1 页
const [pageNum, setPageNum] = useState<number>(1);
// 每页显示数量,默认为 10 条
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
const [total, setTotal] = useState<number>(0); // 查询任务总数
const state = useRef({
pageIndex: 1,
pageSize: 10
})
// 回车处理
const handleKeyDown = async (e) => {
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
e.preventDefault();
} else if (e.keyCode === 13) {
e.preventDefault();
await taskListSearch()
} else if (e.key === "/") {
e.preventDefault();
}
};
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (value: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
setPageNum(value);
state.current.pageIndex = value;
await taskListSearch();
}
// 任务查询
const taskListSearch = async () => {
const param = {
keyword: '',
pageNum: state.current.pageIndex,
pageSize: state.current.pageSize
}
setTaskLoading(true);
const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param);
setTaskLoading(false);
console.log('任务搜索', res);
const result = res.data;
result.forEach((item: TaskListItem) => {
item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比
item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息
})
setTaskList(result || []); // 设置任务列表页
const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数
console.log('totalRes', totalRes)
setTotal(totalRes.data || 0); // 设置总数
if (result.length > 0) {
setTaskId(result[0].id);
await actionListSearch(result[0].id);
}
}
// 行动查询
const actionListSearch = async (taskId: number) => {
const param = {
taskId: taskId,
}
setActionLoading(true);
const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param);
console.log('行动查询', res);
setActionLoading(false);
const result = res.data;
setActionColumnList([
{ columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] },
{ columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] },
{ columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] },
{ columnName: '已终止', icon: '已终止.svg', actionList: result.endDate || [] },
]);
}
const taskDataChange = async (taskId: number) => {
setTaskId(taskId);
await actionListSearch(taskId);
}
useEffect(() => {
taskListSearch();
}, [])
return (
<div className="h-full flex flex-col">
<Row className="h-full flex flex-col">
<Col span={2} className="h-full">
<div className={`h-[calc(100vh-100px)] p-[10px] ${isDark ? 'bg-[rgba(3,38,80,0.6)]' : 'bg-white'} shadow-[2px_0px_6px_rgba(0,0,0,0.12)]`} >
{/* <div className="flex justify-between">
<span>筛选</span>
<span className="flex items-center">
<span>下发时间排序</span>
</span>
</div> */}
<div className="relative flex-1 max-w-md">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
<input
type="text"
placeholder="搜索任务名称"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => handleKeyDown(e)}
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
/>
</div>
{/* 任务列表 */}
<Loading loading={taskLoading}>
<div className="h-[calc(100vh-200px)] overflow-y-auto pr-[10px]">
{
taskList.map((item: TaskListItem, index: number) => (
<div key={index} className={`${isDark ? 'bg-[#0062d91a] border-[#55a6fe1a] shadow-[0_3px_4px_#8b8b8b0f] dark-theme' : 'bg-white border-[#f5f5f5] shadow-[2px_0px_6px_rgba(0,0,0,0.08)] task-item-active'} p-[10px] mt-[10px] mb-[10px] rounded-[4px] border ${taskId === item.id && 'task-active'} cursor-pointer`} onClick={() => taskDataChange(item.id)}>
<div className="text-[var(--text-color)] text-[17px] mb-[5px] font-[500]">{item.taskName}</div>
<div className="text-[#7f7f7f] mb-[5px]">{format(new Date(item.taskStartDate), 'yyyy-MM-dd HH:mm:ss')}</div>
<div className="flex items-center mb-[8px]">
<img src={`/icon/${item?.taskStatusData?.icon}`} alt="" />
<span className="ml-[5px] leading-[16px]">{item?.taskStatusData?.label}</span>
</div>
<div className="pb-[10px] pt-[10px]">
<div className="border-b border-[#e8e8e8]"></div>
</div>
<div className="flex items-center justify-between">
<div className="h-[24px] item-active pl-[10px] pr-[10px] task-tab-color leading-[24px] rounded-[50px] bg-[var(--bg-color)]">
完成{item.endCount}/{item.sumAll}
</div>
<div className="pl-[20px] flex-1">
<Progress percentage={item.progress} label={false} color={item?.taskStatusData?.color} />
</div>
</div>
</div>
))
}
</div>
</Loading>
{/* 分页 */}
<div>
<Pagination
total={total}
size="small"
theme="simple"
current={pageNum}
pageSize={pageSize}
showPageSize={false}
onCurrentChange={(page) => pageIndexChange(Number(page))}
/>
</div>
</div>
</Col>
<Col span={10} className="h-full">
<div className="h-full flex flex-col p-[0_10px]">
<Loading loading={actionLoading}>
<div className="flex grid grid-cols-4 gap-[10px] pl-[10px]">
{
actionColumnList.map((item: actionColumnItem, index: number) => (
<ActionColumn key={index} icon={item.icon} columnIndex={index} columnName={item.columnName} actionCount={item.actionList.length} actionList={item.actionList} title={title} category={category} subCategory={subCategory} />
))
}
{
actionColumnList.length === 0 && <Empty/>
}
</div>
</Loading>
</div>
</Col>
</Row>
</div>
);
}
@@ -0,0 +1,113 @@
import { useTheme } from '@/contexts/ThemeContext';
import { ActionListItem, TaskListItem, taskStatusList } from './TaskManagement';
import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, Search, User, Users, Workflow } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Col, Empty, Pagination, Progress, Row } from 'tdesign-react';
import { requestCrudService } from '@/service/request-axios-service';
interface ActionColumnProps {
TaskType?: string;
title: string;
category: string;
subCategory: string;
icon: string;
columnIndex: number;
columnName: string;
actionCount: number;
actionList: ActionListItem[];
}
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, subCategory, icon, columnName, columnIndex, actionCount, actionList }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState<string>('');
// 当前页码,默认为第 1 页
const [pageNum, setPageNum] = useState<number>(1);
// 每页显示数量,默认为 10 条
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
const [total, setTotal] = useState<number>(0); // 查询任务总数
const state = useRef({
pageIndex: 1,
pageSize: 10
})
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (value: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
setPageNum(value);
state.current.pageIndex = value;
}
const actionRouterChange = (item: ActionListItem) => {
if (item.actionStatus !== 0) {
window.open(`https://ch1.b.uat.4.cn/web/#/action/item-aiceh/step-analysis-three?id=${item.id}`);
} else {
window.open(`https://ch1.b.uat.4.cn/web/#/action/item-aiceh/step-action-topic?id=${item.id}&isPassApply=false`);
}
}
return (
<div className={`h-[calc(100vh-100px)] p-[10px] flex-1 ${isDark? 'dark-action-bg' : `bg-white action-bg${columnIndex} shadow-[2px_0px_6px_rgba(0,0,0,0.12)]`} rounded-[4px]`}>
<div className="mb-[10px]">
<div className="flex items-center mb-[20px]">
<img src={`/icon/${icon}`} className="w-[24px] mr-[10px]" alt="" />
<span className="text-[#222222] text-[18px] font-bold mr-[20px]">{columnName}</span>
<span className="text-[#222222] text-[18px] font-bold mr-[20px]">{actionCount}</span>
</div>
</div>
<div className="h-[calc(100vh-200px)] overflow-y-auto pr-[10px]">
{
actionList.map((item: ActionListItem, index: number) => (
<div key={index} className={`${isDark? 'dark-action-item' : 'bg-white border border-[#f5f5f5] shadow-[0_1px_16px_#0000000f] action-item'} rounded-md px-4 py-2.5 mb-[10px] hover:border-[var(--card-border)] cursor-pointer`} onClick={() => actionRouterChange(item)}>
<div className="line-clamp-4 overflow-hidden mb-[10px]">
{
item.actionName
}
</div>
<div className="flex items-center mb-[10px]">
<span className={`${isDark? 'bg-[#0062d94d] text-[#aaceff]' :'bg-[#f5f5f5]'} leading-[16px] p-[5px_10px] rounded-[2px]`}>
{item.level === 1 ? '主要行动' : '次要行动'}
</span>
</div>
<div className="flex justify-between">
<span className={`${isDark? 'bg-[#0062d94d] text-[#aaceff]' :'bg-[#f5f5f5]'} leading-[16px] p-[5px_10px] rounded-[2px]`}>
{item.deptName}
</span>
<span className="border border-[var(--color-main)] text-[var(--color-main)] cursor-pointer leading-[16px] ml-[10px] p-[5px_10px] rounded-[2px]">
{
item.actionStatus === 0 ? <span>XD签收</span> : <span>开始CEH</span>
}
</span>
</div>
</div>
))
}
{
actionList.length === 0 && <Empty image={
<img
src="/icon/empty-list.png"
alt="empty"
className="w-[135px]"
/>
} />
}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import React, { useEffect, useState } from "react";
import "./css/iframe.css";
import { useTheme } from "@/contexts/ThemeContext";
const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || "";
const FanganTyPage: React.FC = () => {
const { theme } = useTheme();
const isDark = theme === "dark";
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
initIframeUrl();
}, []);
const initIframeUrl = () => {
const url = `${TYURL}/fromB/toPage?defaultPage=iframeFaty`;
console.log({ iframeUrl: url });
setIframeUrl(url);
};
return (
<div className="iframe-container">
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
</div>
);
};
export default FanganTyPage;
@@ -0,0 +1,90 @@
import { useTheme } from '@/contexts/ThemeContext';
import { TaskListItem } from './TaskManagement';
import { ChevronRight, FileText } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { TaskCard } from './TaskCard';
import { Pagination } from 'tdesign-react';
interface KanbanColumnProps {
title: string;
count: number;
color: string;
tasks: TaskListItem[];
taskType: string;
TaskDataType?: string; // 区分来源类型
pageIndexSearch: (taskType: string, pageIndex: number) => void;
handleStartAnalysis: (task: TaskListItem) => void
}
export const KanbanColumn: React.FC<KanbanColumnProps> = ({ title, count, color, tasks,taskType, TaskDataType = 'task' , pageIndexSearch, handleStartAnalysis }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
// 当前页码,默认为第 1 页
const [pageNum, setPageNum] = useState<number>(1);
// 每页显示数量,默认为 10 条
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (value: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
console.log('taskType', taskType)
setPageNum(value);
await pageIndexSearch(taskType, value);
}
/**
* 每页显示数量变化时的回调函数
* 当用户切换每页显示条数时触发,更新每页数量并重新获取数据
* @param value - 新的每页显示数量
*/
const pageSizeChange = async (value: number) => {
// 更新 ref 中存储的每页显示数量
// 更新每页显示数量状态,触发组件重新渲染
setPageSize(value);
}
return (
<div className="flex flex-col min-h-0 flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${color}`} />
<h2 className={`font-semibold text-sm ${textPrimary}`}>{title}</h2>
<span className={`px-2 py-0.5 rounded-full text-xs ${isDark ? 'bg-slate-700/50 text-slate-400' : 'bg-red-50 text-red-500'
}`}>{count}</span>
</div>
{/* <button className={`p-1 rounded transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
}`}>
<ChevronRight className={`w-4 h-4 ${textMuted}`} />
</button> */}
<div>
<Pagination
total={count}
size="small"
theme="simple"
current={pageNum}
pageSize={pageSize}
maxPageBtn={3}
showPageSize={false}
totalContent={false}
onCurrentChange={(page) => pageIndexChange(Number(page))}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-3 pr-1 scrollbar-thin">
{tasks.length === 0 ? (
<div className={`flex flex-col items-center justify-center py-8 ${textMuted}`}>
<FileText className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">暂无任务</p>
</div>
) : (
tasks.map(task => <TaskCard key={task.id} task={task} TaskType={TaskDataType} handleStartAnalysis={handleStartAnalysis}/>)
)}
</div>
</div>
);
}
@@ -0,0 +1,391 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Plus,
Search,
Eye,
Play,
Pause,
Clock,
User,
ChevronRight,
Filter,
FileText,
AlertTriangle,
CheckCircle,
XCircle,
MoreHorizontal,
Edit,
Download,
Bot,
Users,
Workflow,
X,
ArrowRight,
Target,
Layers,
} from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
// 导入请求服务,用于与后端 API 交互
import { requestCrudService } from '@/service/request-axios-service';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from './TaskManagement';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import dayjs from 'dayjs';
import { useChatContext } from '@/contexts/ChatContext';
import { useNavigate } from 'react-router-dom';
interface TaskListPageProps {
title: string;
category: string;
subCategory: string;
}
interface stateItem {
startTime: string,
endTime: string,
task: TaskListItem,
}
type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search';
type TaskStatus = 'draft' | 'pending' | 'running' | 'paused' | 'blocked' | 'completed';
type ColumnType = 'unanalyzed' | 'analyzing' | 'completed';
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const router = useNavigate();
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
const { setStartConfig } = useChatContext();
const [searchQuery, setSearchQuery] = useState('');
const [modeFilter, setModeFilter] = useState<string>('all');
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState<TaskListItem | null>(null);
const [unanalyzedTotal, setUnanalyzedTotal] = useState<number>(0); // 未分析
const [runningTotal, setRunningTotal] = useState<number>(0); // 分析中
const [completedTotal, setCompletedTotal] = useState<number>(0); // 已完成
const [unanalyzedList, setUnanalyzedList] = useState<TaskListItem[]>([]); // 未分析
const [runningList, setRunningList] = useState<TaskListItem[]>([]); // 分析中
const [completedList, setCompletedList] = useState<TaskListItem[]>([]); // 已完成
const [unanalyzedLoading, setUnanalyzedLoading] = useState<boolean>(false); // 未分析
const [runningLoading, setRunningLoading] = useState<boolean>(false); // 分析中
const [completedLoading, setCompletedLoading] = useState<boolean>(false); // 已完成
const [presets] = useState<DateRangePickerProps['presets']>({
最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()],
最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()],
今天: [dayjs().toDate(), dayjs().toDate()],
});
const [range, setRange] = useState<DateRangeValue>([]);
const state = useRef<stateItem>({
startTime: '',
endTime: '',
task: {} as TaskListItem,
})
// 时间日期选择器查询
const setRangeChange = async (val) => {
setRange(val);
console.log('时间日期', val);
state.current.startTime = '';
state.current.endTime = '';
if (val.length > 0) {
state.current.startTime = val[0];
state.current.endTime = val[1];
}
await taskDataChange();
}
// 处理开始分析按钮点击
const handleStartAnalysis = (task: TaskListItem) => {
setSelectedTaskForAnalysis(task);
state.current.task = task;
setShowAnalysisModal(true);
};
// 处理分析模式选择
const handleSelectAnalysisMode = (mode: TaskType) => {
setShowAnalysisModal(false);
console.log('mode', mode);
if (mode === 'consultation') {
console.log('state.current.task', state.current.task)
const sceneItem = JSON.parse(state.current.task.scene);
if (sceneItem.taskJson) {
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...sceneItem.taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
task_context: state.current.task.overview
});
router('/qa-center/mope-chat')
}
console.log('sceneItem', sceneItem);
} else if (mode === 'task-search') {
window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${state.current.task.id}`)
}
};
// 处理卡片点击
const taskListSearch = async (taskType: string, pageNum: number = 1, pageSize: number = 10) => {
let taskStatus: number[] = []
if (taskType === 'unanalyzed') { // 未分析
taskStatus = [0, 10];
setUnanalyzedLoading(true);
} else if (taskType === 'running') { // 分析中
taskStatus = [20];
setRunningLoading(true);
} else if (taskType === 'completed') { // 已完成
taskStatus = [25, 30, 40, 60, 70, 80, 90, 100, 110];
setCompletedLoading(true);
}
const param = {
content: searchQuery,
pageNum: pageNum,
pageSize: pageSize,
taskStatus: taskStatus,
startTime: state.current.startTime,
endTime: state.current.endTime,
taskDirection: ''
}
const res = await service.post(`${zncmUrl}/three/cop-three-tasks`, param);
console.log('任务列表', res);
const result = res.data;
result.records.forEach((item: TaskListItem) => {
item.column = taskType;
if (!item.taskLevel) item.taskLevel = 1;
})
if (taskType === 'unanalyzed') {
setUnanalyzedLoading(false);
setUnanalyzedTotal(result.total);
setUnanalyzedList(result.records);
} else if (taskType === 'running') {
setRunningLoading(false);
setRunningTotal(result.total);
setRunningList(result.records);
} else if (taskType === 'completed') {
setCompletedLoading(false);
setCompletedTotal(result.total);
setCompletedList(result.records);
}
}
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (type: string, pageIndex: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
await taskListSearch(type, pageIndex);
}
const taskDataChange = async (pageNum: number = 1, pageSize: number = 10) => {
taskListSearch('unanalyzed', pageNum, pageSize);
taskListSearch('running', pageNum, pageSize);
taskListSearch('completed', pageNum, pageSize);
}
// 回车处理
const handleKeyDown = async (e) => {
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
e.preventDefault();
} else if (e.keyCode === 13) {
e.preventDefault();
await taskDataChange();
} else if (e.key === "/") {
e.preventDefault();
}
};
useEffect(() => {
taskDataChange();
}, []);
// 渲染看板列
return (
<div className="h-full flex flex-col">
{/* 顶部工具栏 */}
<div className={`rounded-xl p-4 flex-shrink-0 mb-4 ${cardBg} border ${cardBorder}`}>
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600'}`}>
{category}
</span>
<span className={`text-xs ${textMuted}`}>/</span>
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-red-100 text-red-600'}`}>
{subCategory}
</span>
</div>
<h1 className={`text-xl font-bold ${textPrimary}`}>任务中心</h1>
</div>
{/* <button
onClick={() => onNavigate('task-create')}
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Plus className="w-4 h-4" />
新建任务
</button> */}
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
<input
type="text"
placeholder="搜索任务名称"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => handleKeyDown(e)}
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
/>
</div>
{/* <select
value={modeFilter}
onChange={(e) => setModeFilter(e.target.value)}
className={`px-4 py-2 border rounded-lg text-sm focus:outline-none cursor-pointer ${inputBg} ${cardBorder} ${textSecondary}`}
>
<option value="all">全部模式</option>
<option value="single">单智能体</option>
<option value="multi-agent">多智能体协同</option>
<option value="consultation">多智能体会商</option>
<option value="scene">场景执行</option>
</select> */}
{/* <button className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${inputBg} ${cardBorder} ${textSecondary}`}>
<Clock className="w-4 h-4" />
时间范围
</button> */}
<DateRangePicker value={range} presets={presets} onChange={(val) => setRangeChange(val)} enableTimePicker={true} clearable />
</div>
</div>
{/* 统计概览栏 */}
<div className="grid grid-cols-4 gap-4 mb-4 flex-shrink-0">
<button
onClick={() => { setModeFilter('all'); }}
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal + runningTotal + completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>全部任务</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal}</p>
<p className={`text-sm ${textSecondary}`}>未分析</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-blue-400' : 'text-red-600'}`}>{runningTotal}</p>
<p className={`text-sm ${textSecondary}`}>分析中</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-green-400' : 'text-green-600'}`}>{completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>已完成</p>
</button>
</div>
{/* 看板区 */}
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0 overflow-hidden">
<Loading loading={unanalyzedLoading}>
<KanbanColumn title="未分析" taskType={"unanalyzed"} TaskDataType={'sentiment'} count={unanalyzedTotal} color={isDark ? 'bg-yellow-400' : 'bg-yellow-400'} tasks={unanalyzedList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
<Loading loading={runningLoading}>
<KanbanColumn title="分析中" taskType="running" TaskDataType={'sentiment'} count={runningTotal} color={isDark ? 'bg-blue-400' : 'bg-red-400'} tasks={runningList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
<Loading loading={completedLoading}>
<KanbanColumn title="已完成" taskType="completed" TaskDataType={'sentiment'} count={completedTotal} color={isDark ? 'bg-green-400' : 'bg-green-400'} tasks={completedList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
</div>
{/* 选择分析模式弹窗 */}
{showAnalysisModal && selectedTaskForAnalysis && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowAnalysisModal(false)}>
<div
className={`w-full max-w-lg rounded-2xl p-6 ${cardBg} border ${cardBorder} shadow-xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className={`text-xl font-bold ${textPrimary}`}>选择分析模式</h2>
<p className={`text-sm ${textMuted} mt-1`}>为任务「{selectedTaskForAnalysis.taskName}」选择分析方式</p>
</div>
<button
onClick={() => setShowAnalysisModal(false)}
className={`p-2 rounded-lg hover:bg-slate-700/50 ${textSecondary}`}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3">
<button
onClick={() => handleSelectAnalysisMode('task-search')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
}`}>
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>查看任务详情</h3>
<p className={`text-sm ${textSecondary}`}>进入b1系统查看任务详情</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button>
{/* 多智能体会商 */}
<button
onClick={() => handleSelectAnalysisMode('consultation')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-orange-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-red-100'
}`}>
<Users className={`w-6 h-6 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>多智能体会商</h3>
<p className={`text-sm ${textSecondary}`}>多个AI智能体从不同视角开展讨论并形成共识</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default SentimentAnalyzes;
@@ -0,0 +1,648 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Plus,
Search,
Eye,
Play,
Pause,
Clock,
User,
ChevronRight,
Filter,
FileText,
AlertTriangle,
CheckCircle,
XCircle,
MoreHorizontal,
Edit,
Download,
Bot,
Users,
Workflow,
X,
ArrowRight,
Target,
Layers,
} from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
// 导入请求服务,用于与后端 API 交互
import { requestCrudService } from '@/service/request-axios-service';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from './TaskManagement';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { useChatContext } from '@/contexts/ChatContext';
interface TaskListPageProps {
title: string;
category: string;
subCategory: string;
}
type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search';
type TaskStatus = 'draft' | 'pending' | 'running' | 'paused' | 'blocked' | 'completed';
type ColumnType = 'unanalyzed' | 'analyzing' | 'completed';
interface Task {
id: string;
name: string;
type: TaskType;
status: TaskStatus;
agent?: string;
scene?: string;
participants?: string[];
currentPhase?: string;
currentRound?: number;
summary?: string;
creator: string;
createTime: string;
updateTime: string;
column: ColumnType;
}
interface stateItem {
startTime: string,
endTime: string,
task: TaskListItem,
}
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory}) => {
const { theme } = useTheme();
const { setStartConfig } = useChatContext();
const router = useNavigate();
const isDark = theme === 'dark';
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
// 生成模拟数据
const [tasks] = useState<Task[]>([
// 未分析列
{
id: '2026-0320-001',
name: '端午节品牌传播方案',
type: 'scene',
scene: '宣传策略生成',
status: 'draft',
column: 'unanalyzed',
summary: '针对端午节品牌活动制定完整传播策略方案',
creator: '李策划',
createTime: '2026-03-20 10:00',
updateTime: '2026-03-20 10:00',
},
{
id: '2026-0320-002',
name: '新品上市用户调研',
type: 'single',
agent: 'AudienceAgent',
status: 'pending',
column: 'unanalyzed',
summary: '对新品目标用户进行画像分析',
creator: '王运营',
createTime: '2026-03-20 09:30',
updateTime: '2026-03-20 09:30',
},
// 分析中列
{
id: '2026-0319-001',
name: '五四青年节宣传活动策略',
type: 'scene',
scene: '宣传策略生成',
status: 'running',
currentPhase: '行为分析',
column: 'analyzing',
summary: '正在按场景模板执行目标、受众、行为、叙事等分析...',
creator: '张策略',
createTime: '2026-03-19 10:30',
updateTime: '2026-03-20 09:45',
},
{
id: '2026-0319-002',
name: '青年传播方向会商分析',
type: 'consultation',
participants: ['GoalAgent', 'AudienceAgent', 'NarrativeAgent'],
status: 'running',
currentRound: 2,
column: 'analyzing',
summary: '围绕宣传方向、叙事方式和受众切入点开展多视角会商...',
creator: '王策划',
createTime: '2026-03-19 14:00',
updateTime: '2026-03-20 09:00',
},
{
id: '2026-0318-001',
name: '年度品牌升级方案推演',
type: 'scene',
scene: '宣传方案推演',
status: 'paused',
currentPhase: '叙事分析',
column: 'analyzing',
summary: '任务已暂停,等待人工介入确认叙事方向',
creator: '王策划',
createTime: '2026-03-18 14:00',
updateTime: '2026-03-20 08:30',
},
// 已完成列
{
id: '2026-0318-002',
name: '核心受众画像分析',
type: 'single',
agent: 'AudienceAgent',
status: 'completed',
column: 'completed',
summary: '已完成目标受众识别、关切提炼和阻力分析',
creator: '赵运营',
createTime: '2026-03-18 09:00',
updateTime: '2026-03-18 16:30',
},
{
id: '2026-0319-003',
name: '五四青年节宣传策略',
type: 'scene',
scene: '宣传策略生成',
status: 'completed',
column: 'completed',
summary: '已完成宣传策略方案生成',
creator: '张策略',
createTime: '2026-03-19 10:00',
updateTime: '2026-03-19 18:00',
},
{
id: '2026-0319-004',
name: '青年品牌传播路径会商',
type: 'consultation',
participants: ['GoalAgent', 'NarrativeAgent', 'RiskAgent'],
status: 'completed',
column: 'completed',
summary: '已完成多智能体会商,形成结论与分歧报告',
creator: '王策划',
createTime: '2026-03-19 14:30',
updateTime: '2026-03-19 17:00',
},
]);
const [searchQuery, setSearchQuery] = useState('');
const [modeFilter, setModeFilter] = useState<string>('all');
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState<TaskListItem | null>(null);
const [unanalyzedTotal, setUnanalyzedTotal] = useState<number>(0); // 未分析
const [runningTotal, setRunningTotal] = useState<number>(0); // 分析中
const [completedTotal, setCompletedTotal] = useState<number>(0); // 已完成
const [unanalyzedList, setUnanalyzedList] = useState<TaskListItem[]>([]); // 未分析
const [runningList, setRunningList] = useState<TaskListItem[]>([]); // 分析中
const [completedList, setCompletedList] = useState<TaskListItem[]>([]); // 已完成
const [unanalyzedLoading, setUnanalyzedLoading] = useState<boolean>(false); // 未分析
const [runningLoading, setRunningLoading] = useState<boolean>(false); // 分析中
const [completedLoading, setCompletedLoading] = useState<boolean>(false); // 已完成
const [presets] = useState<DateRangePickerProps['presets']>({
最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()],
最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()],
今天: [dayjs().toDate(), dayjs().toDate()],
});
const [range, setRange] = useState<DateRangeValue>([]);
const state = useRef<stateItem>({
startTime: '',
endTime: '',
task: {} as TaskListItem,
})
// 时间日期选择器查询
const setRangeChange = async (val) => {
setRange(val);
console.log('时间日期', val);
state.current.startTime = '';
state.current.endTime = '';
if (val.length > 0) {
state.current.startTime = val[0];
state.current.endTime = val[1];
}
await taskDataChange();
}
// 获取任务类型配置
const getTypeConfig = (type: TaskType) => {
if (isDark) {
switch (type) {
case 'single':
return { label: '单智能体', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot };
case 'scene':
return { label: '场景执行', color: 'bg-purple-500/20 text-purple-400', icon: Workflow };
case 'consultation':
return { label: '多智能体会商', color: 'bg-orange-500/20 text-orange-400', icon: Users };
case 'multi-agent':
return { label: '多智能体协同', color: 'bg-green-500/20 text-green-400', icon: Layers };
}
} else {
switch (type) {
case 'single':
return { label: '单智能体', color: 'bg-blue-100 text-blue-600', icon: Bot };
case 'scene':
return { label: '场景执行', color: 'bg-purple-100 text-purple-600', icon: Workflow };
case 'consultation':
return { label: '多智能体会商', color: 'bg-orange-100 text-orange-600', icon: Users };
case 'multi-agent':
return { label: '多智能体协同', color: 'bg-green-100 text-green-600', icon: Layers };
}
}
};
// 获取状态配置
const getStatusConfig = (status: TaskStatus, column: ColumnType) => {
if (column === 'unanalyzed') {
switch (status) {
case 'draft':
return { label: '草稿', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600' };
case 'pending':
return { label: '待启动', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600' };
default:
return { label: '待配置', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' };
}
} else if (column === 'analyzing') {
switch (status) {
case 'running':
return { label: '运行中', color: isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600' };
case 'paused':
return { label: '已暂停', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' };
case 'blocked':
return { label: '阻塞中', color: isDark ? 'bg-red-500/20 text-red-400' : 'bg-orange-100 text-orange-600' };
default:
return { label: '待处理', color: isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600' };
}
} else {
return { label: '已完成', color: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600' };
}
};
// 筛选任务
const filteredTasks = tasks.filter(task => {
const matchesSearch = task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesMode = modeFilter === 'all' || task.type === modeFilter;
return matchesSearch && matchesMode;
});
const unanalyzedTasks = filteredTasks.filter(t => t.column === 'unanalyzed');
const analyzingTasks = filteredTasks.filter(t => t.column === 'analyzing');
const completedTasks = filteredTasks.filter(t => t.column === 'completed');
// 统计数据
const stats = {
total: tasks.length,
unanalyzed: unanalyzedTasks.length,
analyzing: analyzingTasks.length,
completed: completedTasks.length,
};
// 处理开始分析按钮点击
const handleStartAnalysis = (task: TaskListItem) => {
setSelectedTaskForAnalysis(task);
state.current.task = task;
setShowAnalysisModal(true);
};
// 处理分析模式选择
const handleSelectAnalysisMode = (mode: TaskType) => {
setShowAnalysisModal(false);
console.log('mode', mode);
if (mode === 'consultation') {
console.log('state.current.task', state.current.task)
const sceneItem = JSON.parse(state.current.task.scene);
if (sceneItem.taskJson) {
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...sceneItem.taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: {diverge_rounds: 3},
task_context: state.current.task.overview
});
router('/qa-center/mope-chat')
}
console.log('sceneItem', sceneItem);
} else if (mode === 'task-search') {
window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${state.current.task.id}`)
}
};
const taskListSearch = async (taskType: string, pageNum: number = 1, pageSize: number = 10) => {
let taskStatus: number[] = []
if (taskType === 'unanalyzed') { // 未分析
taskStatus = [0, 10];
setUnanalyzedLoading(true);
} else if (taskType === 'running') { // 分析中
taskStatus = [20];
setRunningLoading(true);
} else if (taskType === 'completed') { // 已完成
taskStatus = [25, 30, 40, 60, 70, 80, 90, 100, 110];
setCompletedLoading(true);
}
const param = {
content: searchQuery,
pageNum: pageNum,
pageSize: pageSize,
taskStatus: taskStatus,
startTime: state.current.startTime,
endTime: state.current.endTime,
taskDirection: ''
}
const res = await service.post(`${zncmUrl}/task/cop-tasks`, param);
console.log('任务列表', res);
const result = res.data;
result.records.forEach((item: TaskListItem) => {
item.column = taskType;
if (!item.taskLevel) item.taskLevel = 1;
})
if (taskType === 'unanalyzed') {
setUnanalyzedLoading(false);
setUnanalyzedTotal(result.total);
setUnanalyzedList(result.records);
} else if (taskType === 'running') {
setRunningLoading(false);
setRunningTotal(result.total);
setRunningList(result.records);
} else if (taskType === 'completed') {
setCompletedLoading(false);
setCompletedTotal(result.total);
setCompletedList(result.records);
}
}
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (type: string, pageIndex: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
await taskListSearch(type, pageIndex);
}
const taskDataChange = async (pageNum: number = 1, pageSize: number = 10) => {
taskListSearch('unanalyzed', pageNum, pageSize);
taskListSearch('running', pageNum, pageSize);
taskListSearch('completed', pageNum, pageSize);
}
// 回车处理
const handleKeyDown = async (e) => {
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
e.preventDefault();
} else if (e.keyCode === 13) {
e.preventDefault();
await taskDataChange();
} else if (e.key === "/") {
e.preventDefault();
}
};
useEffect(() => {
taskDataChange();
}, []);
// 渲染看板列
return (
<div className="h-full flex flex-col">
{/* 顶部工具栏 */}
<div className={`rounded-xl p-4 flex-shrink-0 mb-4 ${cardBg} border ${cardBorder}`}>
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600'}`}>
{category}
</span>
<span className={`text-xs ${textMuted}`}>/</span>
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-red-100 text-red-600'}`}>
{subCategory}
</span>
</div>
<h1 className={`text-xl font-bold ${textPrimary}`}>任务中心</h1>
</div>
<button
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Plus className="w-4 h-4" />
新建任务
</button>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
<input
type="text"
placeholder="搜索任务名称"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => handleKeyDown(e)}
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
/>
</div>
{/* <select
value={modeFilter}
onChange={(e) => setModeFilter(e.target.value)}
className={`px-4 py-2 border rounded-lg text-sm focus:outline-none cursor-pointer ${inputBg} ${cardBorder} ${textSecondary}`}
>
<option value="all">全部模式</option>
<option value="single">单智能体</option>
<option value="multi-agent">多智能体协同</option>
<option value="consultation">多智能体会商</option>
<option value="scene">场景执行</option>
</select> */}
{/* <button className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${inputBg} ${cardBorder} ${textSecondary}`}>
<Clock className="w-4 h-4" />
时间范围
</button> */}
<DateRangePicker value={range} presets={presets} onChange={(val) => setRangeChange(val)} enableTimePicker={true} clearable />
</div>
</div>
{/* 统计概览栏 */}
<div className="grid grid-cols-4 gap-4 mb-4 flex-shrink-0">
<button
onClick={() => { setModeFilter('all'); }}
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal + runningTotal + completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>全部任务</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal}</p>
<p className={`text-sm ${textSecondary}`}>未分析</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-blue-400' : 'text-red-600'}`}>{runningTotal}</p>
<p className={`text-sm ${textSecondary}`}>分析中</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-green-400' : 'text-green-600'}`}>{completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>已完成</p>
</button>
</div>
{/* 看板区 */}
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0 overflow-hidden">
<Loading loading={unanalyzedLoading}>
<KanbanColumn title="未分析" taskType={"unanalyzed"} count={unanalyzedTotal} color={isDark ? 'bg-yellow-400' : 'bg-yellow-400'} tasks={unanalyzedList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
</Loading>
<Loading loading={runningLoading}>
<KanbanColumn title="分析中" taskType="running" count={runningTotal} color={isDark ? 'bg-blue-400' : 'bg-red-400'} tasks={runningList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
</Loading>
<Loading loading={completedLoading}>
<KanbanColumn title="已完成" taskType="completed" count={completedTotal} color={isDark ? 'bg-green-400' : 'bg-green-400'} tasks={completedList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
</Loading>
</div>
{/* 选择分析模式弹窗 */}
{showAnalysisModal && selectedTaskForAnalysis && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowAnalysisModal(false)}>
<div
className={`w-full max-w-lg rounded-2xl p-6 ${cardBg} border ${cardBorder} shadow-xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className={`text-xl font-bold ${textPrimary}`}>选择分析模式</h2>
<p className={`text-sm ${textMuted} mt-1`}>为任务「{selectedTaskForAnalysis.taskName}」选择分析方式</p>
</div>
<button
onClick={() => setShowAnalysisModal(false)}
className={`p-2 rounded-lg hover:bg-slate-700/50 ${textSecondary}`}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3">
{/* 单智能体分析 */}
{/* <button
onClick={() => handleSelectAnalysisMode('single')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-cyan-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-cyan-500/20' : 'bg-red-100'
}`}>
<Bot className={`w-6 h-6 ${isDark ? 'text-cyan-400' : 'text-red-500'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>单智能体分析</h3>
<p className={`text-sm ${textSecondary}`}>选择一个AI智能体完成单一维度的专项分析</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button> */}
{/* 多智能体协同分析 */}
{/* <button
onClick={() => handleSelectAnalysisMode('multi-agent')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
}`}>
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>多智能体协同分析</h3>
<p className={`text-sm ${textSecondary}`}>多个AI智能体并行分析,输出各自的判断和结论</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button> */}
<button
onClick={() => handleSelectAnalysisMode('task-search')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
}`}>
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>查看任务详情</h3>
<p className={`text-sm ${textSecondary}`}>进入b1系统查看任务详情</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button>
{/* 多智能体会商 */}
<button
onClick={() => handleSelectAnalysisMode('consultation')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-orange-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-red-100'
}`}>
<Users className={`w-6 h-6 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>多智能体会商</h3>
<p className={`text-sm ${textSecondary}`}>多个AI智能体从不同视角开展讨论并形成共识</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button>
{/* 场景执行 */}
{/* <button
onClick={() => handleSelectAnalysisMode('scene')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-purple-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-purple-500/20' : 'bg-red-100'
}`}>
<Workflow className={`w-6 h-6 ${isDark ? 'text-purple-400' : 'text-red-500'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>场景执行</h3>
<p className={`text-sm ${textSecondary}`}>按照预设场景模板自动执行多阶段策略分析</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button> */}
</div>
</div>
</div>
)}
</div>
);
};
export default TaskAnalyzes;
@@ -0,0 +1,303 @@
import { useTheme } from '@/contexts/ThemeContext';
import { TaskListItem } from './TaskManagement';
import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, User, Users, Workflow } from 'lucide-react';
import React from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { useChatContext } from '@/contexts/ChatContext';
interface TaskCardProps {
TaskType?: string;
task: TaskListItem;
handleStartAnalysis: (task: TaskListItem) => void;
}
export const TaskCard: React.FC<TaskCardProps> = ({ task, TaskType = 'task', handleStartAnalysis }) => {
const { theme } = useTheme();
const { setStartConfig } = useChatContext();
const isDark = theme === 'dark';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const navigate = useNavigate();
const handleStartChange = (task: TaskListItem) => {
// if (TaskType === 'task') {
// handleStartAnalysis(task);
// } else {
// sceneDataChange(task);
// }
handleStartAnalysis(task);
}
const sceneDataChange = (task: TaskListItem) => {
if (!task.scene) return;
const sceneItem = JSON.parse(task.scene);
if (sceneItem.taskJson) {
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...sceneItem.taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
task_context: task.overview
});
navigate('/qa-center/mope-chat')
}
console.log('sceneItem', sceneItem);
}
// 处理卡片点击
const handleTaskClick = (task: TaskListItem) => {
if (TaskType === 'sentiment') {
console.log('点击了', task)
sceneDataChange(task);
// window.open(`http://ch1.b.uat.4.cn/taskcop/#/situation-overview/action/sentiment?taskId=${task.id}`)
} else {
if (task.column === 'completed') {
// if (task.type === 'consultation') {
// onNavigate('task-consult-result');
// } else {
// onNavigate('strategy-result');
// }
navigate('/strategy-result')
} else if (task.column === 'running') {
// navigate('/task-management/task-consult')
sceneDataChange(task);
// window.open(`http://ch1.b.uat.4.cn/mopeweb/#/mope?taskOverview=${task.overview}`)
// if (task.type === 'scene') {
// onNavigate('task-scene');
// } else if (task.type === 'consultation') {
// onNavigate('task-consult');
// } else {
// onNavigate('task-single');
// }
}
}
};
// 获取任务类型配置
const getTypeConfig = (type: string) => {
if (isDark) {
switch (type) {
case 'single':
return { label: '单智能体', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot };
case 'scene':
return { label: '场景执行', color: 'bg-purple-500/20 text-purple-400', icon: Workflow };
case 'consultation':
return { label: '多智能体会商', color: 'bg-orange-500/20 text-orange-400', icon: Users };
case 'multi-agent':
return { label: '多智能体协同', color: 'bg-green-500/20 text-green-400', icon: Layers };
}
} else {
switch (type) {
case 'single':
return { label: '单智能体', color: 'bg-blue-100 text-blue-600', icon: Bot };
case 'scene':
return { label: '场景执行', color: 'bg-purple-100 text-purple-600', icon: Workflow };
case 'consultation':
return { label: '多智能体会商', color: 'bg-orange-100 text-orange-600', icon: Users };
case 'multi-agent':
return { label: '多智能体协同', color: 'bg-green-100 text-green-600', icon: Layers };
}
}
};
const typeConfig = getTypeConfig('single');
// 获取任务等级
const getLevel = (type: number) => {
const fallback = { label: '一般', color: isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-blue-100 text-blue-600', icon: Bot };
if (isDark) {
switch (type) {
case 1:
return { label: '一般', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot };
case 2:
return { label: '紧急', color: 'bg-purple-500/20 text-purple-400', icon: Workflow };
case 3:
return { label: '加急', color: 'bg-orange-500/20 text-orange-400', icon: Users };
default:
return fallback;
}
} else {
switch (type) {
case 1:
return { label: '一般', color: 'bg-blue-100 text-blue-600', icon: Bot };
case 2:
return { label: '紧急', color: 'bg-purple-100 text-purple-600', icon: Workflow };
case 3:
return { label: '加急', color: 'bg-orange-100 text-orange-600', icon: Users };
default:
return fallback;
}
}
};
const levelConfig = getLevel(task.taskLevel);
const LevelIcon = levelConfig.icon;
// 获取状态配置
const getStatusConfig = (column: string) => {
if (column === 'unanalyzed') {
return { label: '待分析', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' };
} else if (column === 'running') {
return { label: '运行中', color: isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600' };
} else {
return { label: '已完成', color: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600' };
}
};
const statusConfig = getStatusConfig(task.column);
return (
<div
className={`rounded-xl p-4 hover:shadow-md transition-all duration-200 cursor-pointer group theme-card border ${isDark
? 'bg-slate-800/40 backdrop-blur-xl border-blue-800/30 hover:border-blue-500/50'
: 'bg-white border-red-100 hover:shadow-md shadow-red-100'
}`}
onClick={() => handleTaskClick(task)}
>
{/* 卡片头部:模式标签 + 状态标签 */}
<div className="flex items-center justify-between mb-2">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs font-medium ${levelConfig.color}`}>
{LevelIcon && <LevelIcon className="w-3 h-3" />}
{levelConfig.label}
</span>
<span></span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${statusConfig.color}`}>
{statusConfig.label}
</span>
</div>
{/* 任务名称 */}
<h3 className={`font-semibold text-sm mb-1 line-clamp-2 group-hover:text-red-500 transition-colors ${textPrimary}`}>
{task.taskName}
</h3>
{/* 任务编号 */}
<p className={`text-xs mb-2 ${textMuted}`}>TASK-{task.id}</p>
{/* 任务摘要 */}
<p className={`text-xs mb-3 line-clamp-2 ${textSecondary}`}>{task.overview}</p>
{/* 补充信息区 */}
{/* <div className="space-y-1 mb-3">
{task.type === 'single' && task.agent && (
<div className="flex items-center gap-2 text-xs">
<Bot className={`w-3 h-3 ${isDark ? 'text-cyan-400' : 'text-red-500'}`} />
<span className={textSecondary}>执行智能体:</span>
<span className={isDark ? 'text-cyan-400' : 'text-red-600'}>{task.agent}</span>
</div>
)}
{task.type === 'scene' && task.scene && (
<div className="flex items-center gap-2 text-xs">
<Workflow className={`w-3 h-3 ${isDark ? 'text-purple-400' : 'text-red-500'}`} />
<span className={textSecondary}>关联场景:</span>
<span className={isDark ? 'text-purple-400' : 'text-red-600'}>{task.scene}</span>
</div>
)}
{task.type === 'consultation' && (
<>
<div className="flex items-center gap-2 text-xs">
<Users className={`w-3 h-3 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
<span className={textSecondary}>参与智能体:</span>
<span className={isDark ? 'text-orange-400' : 'text-red-600'}>{task.participants?.length || 0}个</span>
</div>
{task.currentRound && (
<div className="flex items-center gap-2 text-xs">
<Clock className={`w-3 h-3 ${textMuted}`} />
<span className={textSecondary}>当前轮次:</span>
<span className={textPrimary}>第{task.currentRound}轮</span>
</div>
)}
</>
)}
{task.column === 'analyzing' && task.currentPhase && (
<div className="flex items-center gap-2 text-xs">
<Target className={`w-3 h-3 ${textMuted}`} />
<span className={textSecondary}>当前阶段:</span>
<span className={textPrimary}>{task.currentPhase}</span>
</div>
)}
</div> */}
{/* 时间信息 */}
<div className={`flex items-center justify-between text-xs pt-3 border-t ${isDark ? 'border-blue-800/30 text-slate-500' : 'border-red-100 text-gray-400'
}`}>
<div className="flex items-center gap-1.5">
<User className="w-3 h-3" />
<span>{task.taskDirection}</span>
</div>
<span>{format(new Date(task.updateTime), 'yyyy-MM-dd HH:mm:ss')}</span>
</div>
{/* 操作按钮区 */}
<div className={`flex items-center gap-2 mt-3 pt-3 border-t ${isDark ? 'border-blue-800/30' : 'border-red-100'
}`}>
{task.column === 'unanalyzed' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Play className="w-3 h-3" />
开始分析
</button>
{/* <button
onClick={(e) => { e.stopPropagation(); onNavigate('task-create'); }}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
}`}
title="编辑"
>
<Edit className={`w-4 h-4 ${isDark ? 'text-slate-400' : 'text-red-400'}`} />
</button> */}
</>
)}
{task.column === 'running' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Play className="w-3 h-3" />
开始分析
</button>
{/* <button
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
className={`px-2 py-1.5 rounded-lg text-xs transition-colors ${isDark
? 'hover:bg-slate-700/50 border border-blue-800/30 text-slate-300'
: 'hover:bg-red-50 border border-red-200 text-red-600'
}`}
>
查看进展
</button> */}
</>
)}
{task.column === 'completed' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
? 'bg-green-600 hover:bg-green-500'
: 'bg-green-600 hover:bg-green-500 shadow-md'
}`}
>
<Eye className="w-3 h-3" />
开始分析
</button>
{/* <button className={`px-2 py-1.5 rounded-lg text-xs transition-colors ${isDark
? 'hover:bg-slate-700/50 border border-blue-800/30 text-slate-300'
: 'hover:bg-red-50 border border-red-200 text-red-600'
}`}>
<Download className="w-4 h-4" />
</button> */}
</>
)}
{/* <button className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
}`} title="更多">
<MoreHorizontal className={`w-4 h-4 ${isDark ? 'text-slate-400' : 'text-red-400'}`} />
</button> */}
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { Outlet } from 'react-router-dom';
import './css/strategy-data.css';
export interface SceneInfo {
applicableTask: string;
createTime: string;
@@ -159,7 +160,7 @@ export const taskStatusList = {
};
export default function TaskManagement() {
return (
<div className="h-full flex flex-col">
<div className="strategy-data h-full flex flex-col">
<Outlet />
</div>
);
@@ -0,0 +1,24 @@
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
const userInfo = localStorage.getItem('userInfo');
const pgLinkUrl = import.meta.env.VITE_AIPI_PGLINKTO_URL;
interface TaskPgFromProps {
gopath: string
}
const TaskPgFrom: React.FC<TaskPgFromProps> = ({gopath}) => {
const [pgUrl, setPgUrl] = useState(pgLinkUrl);
useEffect(() => {
if(userInfo) {
const userData = JSON.parse(userInfo);
setPgUrl(`${pgUrl}?access_token=${userData.accessToken}&goPath=${gopath}`);
}
}, [gopath]);
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={pgUrl}></iframe>
</div>
);
}
export default TaskPgFrom
@@ -0,0 +1,27 @@
import React, { useEffect, useState } from "react";
import "./css/iframe.css";
import { useTheme } from "@/contexts/ThemeContext";
const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || "";
const XXTyPage: React.FC = () => {
const { theme } = useTheme();
const isDark = theme === "dark";
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
initIframeUrl();
}, []);
const initIframeUrl = () => {
const url = `${TYURL}/fromB/toPage?defaultPage=iframeSysystem`;
console.log({ iframeUrl: url });
setIframeUrl(url);
};
return (
<div className="iframe-container">
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
</div>
);
};
export default XXTyPage;
@@ -0,0 +1,27 @@
import React, { useEffect, useState } from "react";
import "./css/iframe.css";
import { useTheme } from "@/contexts/ThemeContext";
const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || "";
const XingdongTyPage: React.FC = () => {
const { theme } = useTheme();
const isDark = theme === "dark";
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
initIframeUrl();
}, []);
const initIframeUrl = () => {
const url = `${TYURL}/fromB/toPage?defaultPage=iframeXdty`;
console.log({ iframeUrl: url });
setIframeUrl(url);
};
return (
<div className="iframe-container">
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
</div>
);
};
export default XingdongTyPage;
@@ -0,0 +1,10 @@
.iframe-container {
width: 100%;
height: calc(100vh - 100px); /* 可根据需求调整 */
}
.full-size-iframe {
width: 100%;
height: 100%;
border: none;
}
@@ -0,0 +1,240 @@
/**
* Strategy task components styles migrated from web-code/index.css
* Used by TaskAnalyzes, SentimentAnalyzes, ActionAnalyzes and related children.
*/
/* ── CSS variables (light) ─────────────────────────────────────────────── */
.strategy-data {
--color-main: #cf1322;
--td-brand-color: #ce2d39;
--bg-color: #fef6f6;
--card-border: #fa9196;
--text-color: #222222;
--card-bg: 0 0% 100%;
--card-hover: 0 86% 97%;
--border-color: 0 76% 89%;
--input-bg: 0 0% 100%;
--input-border: 0 76% 89%;
}
/* ── CSS variables (dark) ────────────────────────────────────────────── */
.dark .strategy-data {
--color-main: #0062d9;
--td-brand-color: #ce2d39;
--bg-color: #0062d966;
--card-border: #fa9196;
--text-color: #ffffff;
--card-bg: 217.2 32.6% 17.5%;
--card-hover: 215 20.2% 25%;
--border-color: 217.2 32.6% 17.5%;
--input-bg: 217.2 32.6% 17.5%;
--input-border: 217.2 32.6% 17.5%;
}
/* ── Theme card (TaskCard, KanbanColumn) ─────────────────────────────── */
.strategy-data .theme-card {
background-color: hsl(var(--card-bg));
}
.strategy-data .theme-card-hover:hover {
background-color: hsl(var(--card-hover));
}
.strategy-data .theme-transition {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
}
/* ── Scrollbar (KanbanColumn) ────────────────────────────────────────── */
.strategy-data .scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #ddd #f1f1f1;
}
.strategy-data .scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.strategy-data .scrollbar-thin::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 3px;
}
.strategy-data .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #555;
}
.strategy-data .scrollbar-thin::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.dark .strategy-data .scrollbar-thin {
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
}
.dark .strategy-data .scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.45);
}
.dark .strategy-data .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.65);
}
.dark .strategy-data .scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
/* ── ActionAnalyzes task sidebar ─────────────────────────────────────── */
.strategy-data .task-item-active:hover {
border: 1px solid var(--color-main);
}
.strategy-data .task-item-active:hover .item-active {
background: var(--color-main);
color: #ffffff;
}
.strategy-data .dark-theme:hover {
background: #0062d966;
color: #ffffff;
border-color: #0062d9 !important;
box-shadow: 0 3px 4px #8b8b8b0f;
}
.strategy-data .action-item:hover {
border-color: var(--card-border);
}
.strategy-data .dark-theme.task-active,
.strategy-data .dark-theme .task-active {
border-color: #0062d9 !important;
}
.strategy-data .task-active {
background-color: var(--bg-color);
border: 1px solid var(--color-main);
}
.strategy-data .task-active .item-active {
background: var(--color-main);
color: #ffffff;
}
.strategy-data .task-tab-color {
color: var(--color-main);
}
.dark .strategy-data .dark-theme .task-tab-color {
color: #ffffff !important;
}
.dark .strategy-data .dark-theme .t-progress__bar {
background-color: #ffffff;
}
/* ── ActionColumn kanban backgrounds ─────────────────────────────────── */
.strategy-data .action-bg0 {
background: linear-gradient(180deg, #f2f9ff, #fff 17.89% 100%);
}
.strategy-data .action-bg1 {
background: linear-gradient(180deg, #fffaed, #fff 17.89% 100%);
}
.strategy-data .action-bg2 {
background: linear-gradient(180deg, #f2fff9, #fff 17.89% 100%);
}
.strategy-data .action-bg3 {
background: linear-gradient(180deg, #f5f5f5, #fff 17.89% 100%);
}
.strategy-data .dark-action-bg {
background: rgba(3, 38, 80, 0.6) !important;
border: 1px solid rgba(43, 143, 254, 0.6) !important;
border-radius: 8px !important;
box-shadow: 0 1px 16px rgba(0, 0, 0, 0.08) !important;
}
.strategy-data .dark-action-item {
background: #0062d91a;
border: 1px solid #2b8ffe99;
border-radius: 8px;
}
.strategy-data .dark-action-item:hover {
background: #0062d966;
border-color: #0062d9;
}
/* ── TDesign overrides inside task pages ─────────────────────────────── */
.strategy-data .t-loading,
.strategy-data .t-loading__parent {
min-height: 0;
}
.strategy-data .t-loading--full {
position: absolute;
inset: 0;
}
.strategy-data .t-loading__overlay {
background: rgb(255 255 255 / 60%);
}
.dark .strategy-data .t-loading__overlay {
background: rgb(15 23 42 / 45%);
}
.strategy-data .t-pagination {
justify-content: center;
}
.strategy-data .t-date-range-picker,
.strategy-data .t-date-picker,
.strategy-data .t-input {
width: 100%;
max-width: 360px;
}
.strategy-data .t-input__wrap,
.strategy-data .t-range-input {
background: var(--td-bg-color-container, #fff);
}
.dark .strategy-data .t-input__wrap,
.dark .strategy-data .t-range-input {
background: var(--td-bg-color-container, #1e293b);
color: var(--td-text-color-primary, #e2e8f0);
}
.strategy-data .t-row,
.strategy-data .t-col {
min-height: 0;
}
/* Popups are portaled to body — keep panels above app chrome */
.t-popup,
.t-date-picker__panel-container,
.t-date-range-picker__panel-container {
z-index: 6500;
}
/* ── iframe embed pages (resource management, TY tasks, etc.) ─────────── */
.strategy-data .iframe-container {
width: 100%;
height: calc(100vh - 100px);
}
.strategy-data .full-size-iframe {
width: 100%;
height: 100%;
border: none;
}
+48
View File
@@ -0,0 +1,48 @@
import axios from 'axios'
// axios.interceptors
const service = axios.create({
// baseURL: '', // url = base url + request url
// baseURL: '/api',
// withCredentials: true, // send cookies when cross-domain requests
timeout: 500000000*1000 // request timeout
})
service.interceptors.response.use(
response => {
const res = response.data
// if(!sessionStorage.getItem('userName')){
// // window.location = '/#/lagchain-login'
// }
if (res.code !== 0) {
return res
} else {
return res
}
},
error => {
if(!error.response) return Promise.reject(error)
if (error.response.status == 401) {
// window.location = sessionStorage.getItem('mhUrl');
} else {
return Promise.reject(error)
}
}
)
service.interceptors.request.use(
config => {
// let token: any = localStorage.getItem('token');
config.headers['Authorization'] = `Bearer admin`;
// if (token) {
// token = JSON.parse(token);
// // 在请求头中添加 token
// config.headers['Authorization'] = `Bearer ${token.token}`;
// }
return config
},
error => {
return Promise.reject(error)
}
)
export default service
+46
View File
@@ -0,0 +1,46 @@
import axios from 'axios'
// axios.interceptors
const toolsService = axios.create({
// baseURL: '', // url = base url + request url
// baseURL: '/api',
// withCredentials: true, // send cookies when cross-domain requests
timeout: 500000000*1000 // request timeout
})
toolsService.interceptors.response.use(
response => {
const res = response.data
// if(!sessionStorage.getItem('userName')){
// // window.location = '/#/lagchain-login'
// }
if (res.code !== 0) {
return res
} else {
return res
}
},
error => {
if(!error.response) return Promise.reject(error)
if (error.response.status == 401) {
// window.location = sessionStorage.getItem('mhUrl');
} else {
return Promise.reject(error)
}
}
)
toolsService.interceptors.request.use(
config => {
const token: any = localStorage.getItem('toolToken');
if (token) {
// 在请求头中添加 token
config.headers['Authorization'] = `Bearer ${token}`;
}
return config
},
error => {
return Promise.reject(error)
}
)
export default toolsService