diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 41decf09..6228c720 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -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 diff --git a/frontend-web/.env.development.local.example b/frontend-web/.env.development.local.example index 471b2649..f01f2187 100644 --- a/frontend-web/.env.development.local.example +++ b/frontend-web/.env.development.local.example @@ -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 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 30e82ad6..adb14fb8 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -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= diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 0e94e082..0f10b55c 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -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 diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index e759d19d..4492f3e5 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -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:`,遇到开发机 `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-) +协调智能体(单例): + 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(情报收集): -- agent_name: roundtable-environment(环境评估): +【圆桌系统初始化】本次研讨可调度的席位清单如下,请严格按 agent_name 通过 agent_orchestration 技能派活,不要派给不在清单内的 agent: +- agent_name: roundtable-intelligence(情报收集): +- agent_name: roundtable-environment(环境评估): ... ``` -中文显示名通过 `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:` 真实 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 + 推荐流程跑不起来,跟客户业务无关 diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index 88da48a8..89ee2963 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -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` 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-` 形式。只要那个旧 agent 目录还在磁盘上(即运维没手动删过),加载后继续在 Step 2 干活就能正常工作;如果旧 agent 被清理掉了,需要让用户「重新起草」从头跑一遍 Step 1 → Step 2。 ### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)` diff --git a/frontend-web/src/assets/icon/down-logo.svg b/frontend-web/src/assets/icon/down-logo.svg new file mode 100644 index 00000000..19343061 --- /dev/null +++ b/frontend-web/src/assets/icon/down-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/icon/nav-active.svg b/frontend-web/src/assets/icon/nav-active.svg new file mode 100644 index 00000000..776703e6 --- /dev/null +++ b/frontend-web/src/assets/icon/nav-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/icon/nav-line-logo.svg b/frontend-web/src/assets/icon/nav-line-logo.svg new file mode 100644 index 00000000..0f31a8e2 --- /dev/null +++ b/frontend-web/src/assets/icon/nav-line-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 61890fb3..de2edd84 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -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; } \ No newline at end of file diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index 251e1d82..aa18608f 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -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>(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) => ( +
+ {items.map((item) => { + const hasChildren = !!item.children?.length; + const isExpanded = expandedIds.has(item.id); + const isSelected = item.path ? pathname === item.path : hasActiveChild(item); + + return ( +
+ + {hasChildren && isExpanded ? renderFlyoutMenu(item.children ?? [], level + 1) : null} +
+ ); + })} +
+ ); return ( <>
-
- 问答管理 +
+ {activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null}
+ {visibleMenuItems.map((item) => ( + + ))}
diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 810e9e74..11dec345 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -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", + }, ], }, ]; diff --git a/frontend-web/src/contexts/ChatContext.tsx b/frontend-web/src/contexts/ChatContext.tsx index 67682896..6472062d 100644 --- a/frontend-web/src/contexts/ChatContext.tsx +++ b/frontend-web/src/contexts/ChatContext.tsx @@ -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(undefined); @@ -28,6 +43,9 @@ export function ChatProvider({ children }: { children: ReactNode }) { } }); + const [startConfig, setStartConfig] = useState(null); + const [toolsUser, setToolsUser] = useState(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 ( - + {children} ); diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx index 0aa7de26..7a5dcc9c 100644 --- a/frontend-web/src/contexts/ThemeContext.tsx +++ b/frontend-web/src/contexts/ThemeContext.tsx @@ -48,16 +48,16 @@ const themes: Record = { 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: { diff --git a/frontend-web/src/core/strategy/external-links.ts b/frontend-web/src/core/strategy/external-links.ts new file mode 100644 index 00000000..2f9fd1fd --- /dev/null +++ b/frontend-web/src/core/strategy/external-links.ts @@ -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"; +} diff --git a/frontend-web/src/main.tsx b/frontend-web/src/main.tsx index 1bdda945..924e3e76 100644 --- a/frontend-web/src/main.tsx +++ b/frontend-web/src/main.tsx @@ -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"; diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index c9b5ba9f..9efa88b2 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -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 流式打字(含 思考分离),select_skills 完成后自动清空 */} + {state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && ( + + )} + + {/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */} + {state.status === 'researching' && state.skillSearchSteps.length > 0 && ( +
+ +
+ )} + {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 && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx index 6f652103..0230c061 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx @@ -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 ; + return ( + + ); } if (pausePoint === 'outline_confirm') { @@ -106,30 +119,40 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set void; disabled?: boolean; } -function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) { +function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) { const [selectedIds, setSelectedIds] = useState>( () => new Set(materials.map((m) => m.id)), ); - const [extraKeywords, setExtraKeywords] = useState([]); + 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

未检索到任何素材

- 素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。 + 素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。

+ {(skillSearchSteps.length > 0 || skillSearchInProgress) && ( + + )} + 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} /> -
+
+ @@ -182,11 +217,14 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC

需要您确认素材,才能继续

- 已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。 + 已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。 + 您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。

+ {skillSearchSteps.length > 0 && } + { 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} /> -
+
+ {!isSkillResult && ( + + )}
diff --git a/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx b/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx index 539d90ff..b8a6cace 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx @@ -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 (
{/* 头部行 */} @@ -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 && ( + + + 技能 + + )} {material.title} diff --git a/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx b/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx index aa38973e..1b149ed4 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx @@ -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; 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(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) { + 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) { + if (e.key === 'Enter' && !e.shiftKey && onSubmitQuery) { + e.preventDefault(); + onSubmitQuery(); + } } return ( @@ -51,50 +62,21 @@ export function MaterialsToolbar({
)} - {/* 补充关键词 */} + {/* 单输入框 */}
- -
- 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" - /> - -
- - {/* 已添加关键词 Tag 列表 */} - {extraKeywords.length > 0 && ( -
- {extraKeywords.map((kw) => ( - - {kw} - - - ))} -
- )} + +