From b91a45639f266538c7508d9348f8c52d44f19329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 25 May 2026 20:23:33 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/docs/multi-agent-frontend-dev.md | 18 ++- .../pages/RoundtablePlanningPage.tsx | 108 +++++++++++++++--- .../backend/app/gateway/routers/intent.py | 10 +- .../app/gateway/routers/multi_agent.py | 10 +- .../backend/app/gateway/routers/recommend.py | 7 +- 5 files changed, 121 insertions(+), 32 deletions(-) diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index 298536ec..f4336e7c 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -10,7 +10,7 @@ - **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。 - **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。 -- **唯一默认模型**:`DEFAULT_MODEL = "deepseek-chat"`(页面常量,写在 `RoundtablePlanningPage.tsx`)。`useModels()` 只用于 Persona 抽屉的下拉选项。 +- **不写死模型名**:与 `/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`)。 - **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。 - **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。 @@ -364,13 +364,22 @@ isOrchestrating = false - 抽屉控件: - **System Prompt**:仅前端记录(后端覆写未开放) - **Temp**:仅前端记录 - - **推理模型**:选项来自 `useModels()`,默认 `DEFAULT_MODEL` + - **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel` - 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`: - 把新的 `(seatId, model)` 写入 `agentConfigs` - 追加一条「⚡ [模型偏好已保存] ...」对话气泡 - - **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model` 作为 special 调用的 `model` 参数 + - **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数 -### 5.6 草稿持久化(`utils/drafts.ts`) +### 5.6 全局模型选择器(顶栏) + +- **位置**:顶栏右侧「新建任务」按钮左边,`` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变 + +### 5.7 草稿持久化(`utils/drafts.ts`) - Key:`roundtable-planning:drafts:v1`(`localStorage`) - 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep` @@ -544,7 +553,6 @@ interface StreamMultiAgentResult { | 常量 | 值 | 位置 | |------|----|------| -| `DEFAULT_MODEL` | `"deepseek-chat"` | RoundtablePlanningPage | | `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage | | `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) | | `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog | diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index abecde29..4ae16256 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -26,7 +26,13 @@ * 7) **Persona 抽屉**改的 model 不会立即重启编排,下一轮该席位被派活时由 `runOrchestration` * 读 `agentConfigs[meta.speakerId]?.model` 传入 special。prompt / temp 当前**仅前端记录**。 * - * 8) **常量**:`DEFAULT_MODEL`、`MAX_CYCLES=8`、`COMMANDER_SEAT_ID=1`、`INTENT_READY_RE`。 + * 8) **模型选择**:不再硬编码 "deepseek-chat"。全局选中 model 由 `selectedModel` + * state 维护(首次 `useModels()` 完成后初始化为 `availableModels[0].name`), + * 顶栏「模型」下拉可切换;Persona 抽屉的 per-seat 覆盖更高优先级。所有 API 调用 + * 传 `selectedModel || undefined`,后端缺省由 `lead_agent._resolve_model_name()` + * 回退到 `config.yaml` 的 `models[0]`。内网部署只需配 `config.yaml` 即可。 + * + * 9) **常量**:`MAX_CYCLES=8`、`COMMANDER_SEAT_ID=1`、`INTENT_READY_RE`。 * * 调试开关(默认开启): * window.__MULTI_AGENT_INIT_TIMING__ // /api/multi-agent/init 各阶段耗时 @@ -189,7 +195,13 @@ function getAvatar(type: string, size: string = "h-8 w-8") { } // --- Multi-agent backend wiring --- -const DEFAULT_MODEL = "deepseek-chat"; +// +// 模型选择策略(与 /page/canvas/ai-writing 对齐,不再写死 "deepseek-chat"): +// - 全局选中模型由 selectedModel state 维护(首次 useModels 完成后初始化为 models[0].name) +// - 所有 API 调用传 model 时统一用 selectedModel || undefined;后端缺省由 +// lead_agent._resolve_model_name() 回退到 config.yaml 的 models[0] +// - Persona 抽屉的 per-seat 覆盖优先(agentConfigs[seat]?.model > selectedModel) +// 内网部署只需在 config.yaml 配自家模型,前后端都不需要再硬编码具体模型名。 type RoleEntry = { id: number; @@ -373,16 +385,24 @@ export default function RoundtablePlanningPage() { // for each of them concurrently instead of only the most recently set one. const [activeSpeakerSet, setActiveSpeakerSet] = useState>(new Set()); - // Custom presets for agent model, weight, temperature and prompt editors + // Custom presets for agent model, weight, temperature and prompt editors. + // model 字段留空,运行时由 runOrchestration 解析为 + // agentConfigs[seat]?.model || selectedModel || undefined + // 即「席位覆盖优先,否则跟随全局选中模型,否则让后端回退到 config.yaml models[0]」。 const [agentConfigs, setAgentConfigs] = useState<{ [key: number]: { model: string; weight: number; prompt: string; temp: number } }>({ - 2: { model: DEFAULT_MODEL, weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" }, - 3: { model: DEFAULT_MODEL, weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" }, - 4: { model: DEFAULT_MODEL, weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" }, - 5: { model: DEFAULT_MODEL, weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" }, - 6: { model: DEFAULT_MODEL, weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" }, - 7: { model: DEFAULT_MODEL, weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" }, + 2: { model: "", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" }, + 3: { model: "", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" }, + 4: { model: "", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" }, + 5: { model: "", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" }, + 6: { model: "", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" }, + 7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" }, }); + // 全局选中模型(对应顶栏「模型」下拉)。 + // 首次 useModels 加载完成后由下面的 useEffect 初始化为 availableModels[0].name, + // 用户可以通过下拉框切换;Persona 抽屉的 per-seat 覆盖更高优先级。 + const [selectedModel, setSelectedModel] = useState(""); + // Message lists state representation to mimic image items & enable actual conversations const [interactiveMessages, setInteractiveMessages] = useState([ { @@ -561,6 +581,17 @@ export default function RoundtablePlanningPage() { } }, []); + // 首次 /api/models 返回后,把 selectedModel 初始化为列表第一个 —— 与 + // AIWritingForm 的行为对齐。若 selectedModel 已经被用户在下拉框里选过,不再覆盖。 + // 若当前选中的 model 已不在 availableModels 里(后端配置变了),也回退到首项。 + useEffect(() => { + if (availableModels.length === 0) return; + const stillExists = availableModels.some((m) => m.name === selectedModel); + if (!selectedModel || !stillExists) { + setSelectedModel(availableModels[0].name); + } + }, [availableModels, selectedModel]); + // Toast auto-dismiss logic useEffect(() => { if (showToast) { @@ -837,7 +868,7 @@ export default function RoundtablePlanningPage() { dAgentThreadId: ids, agentName: coord, newMessage: nextLeaderTask, - model: DEFAULT_MODEL, + model: selectedModel || undefined, signal: leaderAborter.signal, onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onPhaseChange: (phase, toolName) => { @@ -918,7 +949,8 @@ export default function RoundtablePlanningPage() { dAgentThreadId: ids, agentName: subName, newMessage: task, - model: perSeatModel || DEFAULT_MODEL, + // 优先级: 席位 Persona 覆盖 → 全局选中模型 → 让后端回退到 config.yaml models[0] + model: perSeatModel || selectedModel || undefined, signal: subAborter.signal, onTextDelta: (text) => appendToDialogue(subBubbleId, text), onPhaseChange: (phase, toolName) => { @@ -1026,7 +1058,7 @@ export default function RoundtablePlanningPage() { const initRes = await initMultiAgent({ agentNames: roundtableAgentIds, mainAgentName: coord, - model: DEFAULT_MODEL, + model: selectedModel || undefined, // As each sub-agent's thread comes online, flip every role that // shares the agentId to "在线". Some seats reuse the same agent // (e.g. seat 8 reuses roundtable-summary) so we match on agentId @@ -1148,7 +1180,9 @@ export default function RoundtablePlanningPage() { const role = roles.find(r => r.id === id); const roleName = role?.name || "专家智能体"; const cfg = agentConfigs[id]; - const targetModel = cfg?.model || DEFAULT_MODEL; + // Persona 抽屉里的「保存」反馈文字: 优先显示用户在抽屉里选的 model, + // 没选时退回全局 selectedModel,最后才显示「默认」二字。 + const targetModel = cfg?.model || selectedModel || "默认模型"; const avatarColor = id === 2 ? "blue" @@ -1206,7 +1240,9 @@ export default function RoundtablePlanningPage() { ]); (async () => { try { - const res = await initIntent(DEFAULT_MODEL); + // initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端 + // 走 lead_agent._resolve_model_name 回退到 config.yaml models[0] + const res = await initIntent(selectedModel || undefined); if (step1InitGenRef.current !== myGen) return; setIntentThreadId(res.thread_id); setTimelineEntries([ @@ -1540,7 +1576,7 @@ export default function RoundtablePlanningPage() { const res = await streamIntent({ threadId: intentThreadId, message: trimmed, - model: DEFAULT_MODEL, + model: selectedModel || undefined, signal: aborter.signal, onTextDelta: (delta) => { setInteractiveMessages(prev => @@ -1791,7 +1827,7 @@ export default function RoundtablePlanningPage() { setIsRecommendDialogOpen(false)} onConfirm={handleConfirmAgents} /> @@ -1919,6 +1955,41 @@ export default function RoundtablePlanningPage() { {/* Right Actions */}
+ {/* 全局模型选择器 + ───────────────────────────────────────────── + 影响 Step 1 / 推荐 / Step 2 init / 总控 leader / 子席位 special + 所有 API 调用。Persona 抽屉里 per-seat 设置的 model 优先级更高。 + 选项来自 /api/models(由 config.yaml 配置),内网部署只需配自家模型。 */} + +
{intentInitError && ( -

- 智能体初始化失败: {intentInitError} -

+
+
+ 智能体初始化失败 + {intentInitErrorDetail?.code && ( + + {intentInitErrorDetail.code} + + )} + {intentInitErrorDetail?.exception_type && ( + + {intentInitErrorDetail.exception_type} + {intentInitErrorDetail.root_type && intentInitErrorDetail.root_type !== intentInitErrorDetail.exception_type + ? ` → ${intentInitErrorDetail.root_type}` + : ""} + + )} +
+

{intentInitError}

+ {intentInitErrorDetail?.hint && ( +

+ 💡 建议: {intentInitErrorDetail.hint} +

+ )} + {intentInitErrorDetail?.trace_tail && intentInitErrorDetail.trace_tail.length > 0 && ( +
+ + 展开 traceback 末段(共 {intentInitErrorDetail.trace_tail.length} 行) + +
+{intentInitErrorDetail.trace_tail.join("\n")}
+                            
+
+ )} +
)} )} @@ -3652,6 +3670,44 @@ export default function RoundtablePlanningPage() { ) : ( <> + {/* Panel 0: 推理模型选择器 + ───────────────────────────────────────────────────── + 全局选中模型(selectedModel)影响 Step 1 / 推荐 / Step 2 init / + 总控 leader / 子席位 special 所有 API 调用。Persona 抽屉里 + per-seat 覆盖优先级更高。 + 选项来自 /api/models(由 config.yaml 配置),内网部署只需配自家模型。 */} +
+
+ +

推理模型

+
+ +
+ {/* Panel 1: 当前意图摘要 */}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index b97397d6..425b24db 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -66,6 +66,7 @@ from fastapi.responses import StreamingResponse from app.gateway.routers.multi_agent import ( _auth_headers, _create_thread, + _diagnose_exception, _find_dispatch_message, _find_trailing_ai_text, _flatten_content, @@ -112,8 +113,20 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, # on the dev machine) makes httpx route loopback calls through an upstream # proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per # request before falling back. Symptom in logs: `headers_in=42.160s`. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: - thread_id = await _create_thread(client, base, headers) + try: + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + thread_id = await _create_thread(client, base, headers) + except HTTPException: + # 上游已用结构化方式失败,原样传给前端不需要二次包装。 + raise + except Exception as exc: + # 把根因暴露给前端而不是吞成裸 500——内网部署常见的「config.yaml 模型外网不通」 + # / 「内置 agent 目录缺失」/ 「loopback 走代理」都能在前端面板直接看到。 + # FastAPI 把 detail=dict 序列化为 {"detail": {...}};前端 initIntent + # 在 !res.ok 分支里读 detail.hint / trace_tail 渲染诊断面板。 + diagnostic = _diagnose_exception(exc, context="intent_init") + logger.error("intent init failed: %s", diagnostic, exc_info=exc) + raise HTTPException(status_code=500, detail=diagnostic) from exc return { "status": "success", "agent_id": INTENT_AGENT_ID, @@ -270,11 +283,40 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin except HTTPException as exc: logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail) - err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None} + # HTTPException 的 detail 可能本身就是 dict(上游已结构化),也可能是 str。 + # 统一包装成与 _diagnose_exception 同构的字段,便于前端按一种模型渲染。 + detail_payload: Any = exc.detail + error_detail: dict[str, Any] + if isinstance(detail_payload, dict) and "code" in detail_payload: + error_detail = detail_payload + else: + error_detail = { + "code": "http_exception", + "message": f"[{exc.status_code}] {detail_payload}", + "exception_type": "HTTPException", + "exception_repr": repr(exc), + "root_type": None, + "root_repr": None, + "hint": None, + "trace_tail": [], + "context": "intent_stream", + } + err = { + "status": "error", + "content": error_detail["message"], + "summary": None, + "error_detail": error_detail, + } yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" except Exception as exc: - logger.exception("intent stream unexpected failure") - err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None} + diagnostic = _diagnose_exception(exc, context="intent_stream") + logger.error("intent stream failed: %s", diagnostic, exc_info=exc) + err = { + "status": "error", + "content": diagnostic["message"], + "summary": None, + "error_detail": diagnostic, + } yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" return StreamingResponse( diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index afcd3de1..ce41e820 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -51,6 +51,7 @@ import json import logging import re import time +import traceback from typing import Any, AsyncIterator import httpx @@ -62,6 +63,101 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"]) +# --------------------------------------------------------------------------- +# 共享:异常诊断器(intent/recommend/multi-agent 共用) +# --------------------------------------------------------------------------- + + +def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, Any]: + """把后端异常转成结构化诊断 dict,直接给前端展示用。 + + 设计目标:让运维 / 前端开发不用翻 backend 日志就能看到根因和处置建议。 + 返回字段: + - code: 短稳定分类(loopback_connect_failed / upstream_connect_failed / ...) + - message: 一行人类可读 + - exception_type: 表层异常类名 + - exception_repr: repr(exc) + - root_type / root_repr: 沿 __cause__ 链找到的最深异常(若与表层不同) + - hint: 针对常见失败模式的可执行建议(中文) + - trace_tail: traceback 最后 ~12 行(避免响应体过大) + - context: 调用方标签(如 "intent_init") + """ + exc_type = type(exc).__name__ + exc_repr = repr(exc) + + # 沿 __cause__ 链找最深异常:httpx 经常把 ConnectError 包成 RemoteProtocolError 等。 + root: BaseException = exc + seen: set[int] = {id(root)} + while getattr(root, "__cause__", None) is not None and id(root.__cause__) not in seen: + root = root.__cause__ # type: ignore[assignment] + seen.add(id(root)) + + code = "unexpected" + hint = "查看 trace_tail 最后一帧定位代码位置;若信息不足请到 backend 日志找完整 traceback。" + + if isinstance(root, httpx.ConnectError): + target = str(root).strip().lower() + # loopback 失败 = Gateway 自己没起 / 代理拦回环;outbound 失败 = LLM 提供商不可达。 + if "127.0.0.1" in target or "localhost" in target or "loopback" in target: + code = "loopback_connect_failed" + hint = ( + "本地 Gateway loopback 连接失败:确认后端进程在 8001 端口监听;" + "另检查 HTTP_PROXY / HTTPS_PROXY 是否把 127.0.0.1 也走了代理" + "(代码已显式 trust_env=False,理论上不该走代理)。" + ) + else: + code = "upstream_connect_failed" + hint = ( + "外网/上游服务连接失败:大概率是 LLM 提供商不可达" + "(如 api.deepseek.com 在内网被防火墙拦截)。" + "把 config.yaml 里所有 base_url 改成内网可达的 LLM endpoint," + "或加白名单/正向代理(HTTPS_PROXY)。" + ) + elif isinstance(root, httpx.TimeoutException): + code = "upstream_timeout" + hint = ( + "上游请求超时:网络抖动 / 模型推理过慢。" + "先 curl /v1/models 确认可达,再看模型是否过载或 max_tokens 过大。" + ) + elif isinstance(root, httpx.HTTPStatusError): + code = "upstream_http_error" + status = getattr(getattr(root, "response", None), "status_code", "?") + hint = ( + f"上游接口返回 HTTP {status}:可能是 API key 失效、模型名不对、" + "或目标服务自身报错。查看 backend 日志里 response.text 拿详细错误体。" + ) + elif isinstance(root, FileNotFoundError): + code = "file_not_found" + hint = ( + f"找不到路径:{root}。常见原因:内置 agent 目录(.deer-flow/agents//)缺失," + "或 SOUL.md 没拷过来;详见后端开发文档 §11.7。" + ) + elif isinstance(root, KeyError): + code = "key_error" + hint = ( + f"缺少 key:{root}。若 key 是 agent_name/agent_id 类:大概率内置 agent 目录缺失;" + "若是配置 key:检查 config.yaml schema。" + ) + elif isinstance(root, ValueError) and "model" in str(root).lower(): + code = "no_models_configured" + hint = "config.yaml 没有任何 models[],或所有条目都解析失败。检查 yaml 语法与 $ENV 引用。" + + trace_lines = traceback.format_exception(type(exc), exc, exc.__traceback__) + trace_tail = "".join(trace_lines).splitlines()[-12:] + + return { + "code": code, + "message": f"{exc_type}: {exc}" if str(exc) else exc_type, + "exception_type": exc_type, + "exception_repr": exc_repr, + "root_type": type(root).__name__ if root is not exc else None, + "root_repr": repr(root) if root is not exc else None, + "hint": hint, + "trace_tail": trace_tail, + "context": context or None, + } + + # --------------------------------------------------------------------------- # Loopback 辅助函数(Gateway → Gateway,同进程) # --------------------------------------------------------------------------- From 48d22e0d420ea753cb6893f1f758d7135671ff83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 25 May 2026 22:51:38 +0800 Subject: [PATCH 05/10] =?UTF-8?q?ui=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/docs/ui修改-深蓝.md | 702 +++++++++++++++++++++++++++++++ 1 file changed, 702 insertions(+) diff --git a/frontend-web/docs/ui修改-深蓝.md b/frontend-web/docs/ui修改-深蓝.md index 3aaa8028..a2d6a432 100644 --- a/frontend-web/docs/ui修改-深蓝.md +++ b/frontend-web/docs/ui修改-深蓝.md @@ -711,6 +711,8 @@ ## 9. 锚点与页签 > 对应"4.2 导航组件-锚点"。三类组件共用主色蓝指示当前位置:**页签 / 锚点 / 回到顶部**。 +> +> 视觉参考:标签横向 / 标签纵向 / 锚点的三联图见设计稿「选项卡.png」——三种形态在本节均已规范化(§9.1 = 标签横纵向、§9.2 = 锚点)。 ### 9.1 页签(Tabs) @@ -833,3 +835,703 @@ Hover 状态下方显示 `回到顶部` 提示气泡: | 页签 | 同层级内容切换(多 Tab 互斥) | 文字 + 下划线(横/纵) | | 锚点 | 长页面内章节跳转 | 文字 + 左侧指示条 + 层级缩进 | | 回到顶部 | 长页面快速返回起点 | 圆形悬浮按钮 + 上箭头 | + +--- + +## 10. 步骤条 + +> 对应「4.7 导航组件-步骤条」。用于多步流程的进度指引,按布局方向分**横向**与**纵向**两类,按使用场景分**弹窗步骤条**、**页面步骤条**、**纵向步骤条**三种。所有颜色取自 ZS-002 主色蓝与 ZS-004 深蓝系。 + +### 10.1 步骤节点(公共) + +每个步骤由「指示圆 + 标题(可选描述)」组成。指示圆按状态切换三种形态: + +| 状态 | 圆形外观 | 圆内符号 | 来源 | +|------|----------|----------|------| +| 已完成(Completed) | 描边圆,`2px solid #1677FF`,背景 `transparent` | `✓`(对勾,`#1677FF`) | ZS-002-06 | +| 当前(Current / Active) | 实心圆,背景 `#1677FF` | 步骤数字,`#FFFFFF` | ZS-002-06 / 白 | +| 未开始(Upcoming) | 描边圆,`1px solid #4A6491`,背景 `transparent` | 步骤数字,`#6E88B0` | ZS-004-05 / ZS-004-04 | +| 失效(Disabled) | 描边圆,`1px solid #2D4473`,背景 `transparent` | 步骤数字,`#4A6491` | ZS-004-06 / ZS-004-05 | + +**指示圆规格**: + +| 属性 | 取值 | +|------|------| +| 直径 | `24px` | +| 圆内字号 | `12px Bold`(D-DIN 或 Noto Sans Bold) | +| 对勾图标 | `12px`,居中 | +| 字体水平居中 | 数字 / 对勾 在圆几何中心 | + +**标题与描述文字**: + +| 元素 | 状态 | 字号 / 颜色 | 来源 | +|------|------|-------------|------| +| 标题 | 已完成 / 当前 | `14px Medium`,`#FFFFFF` | — | +| 标题 | 未开始 | `14px Medium`,`#6E88B0` | ZS-004-04 | +| 标题 | 失效 | `14px Medium`,`#4A6491` | ZS-004-05 | +| 描述 | 已完成 / 当前 | `12px Regular`,`#C5CFE0` | ZS-004-02 | +| 描述 | 未开始 / 失效 | `12px Regular`,`#6E88B0` → `#4A6491` | ZS-004-04 → ZS-004-05 | + +**连接线(步骤之间)**: + +| 类型 | 色值 | 来源 | 样式 | +|------|------|------|------| +| 已完成段(前一节点已完成) | `#1677FF` | ZS-002-06 | `1px dashed`(弹窗)/ `1px solid`(页面) | +| 未完成段 | `#4A6491` | ZS-004-05 | `1px dashed` | + +> 连接线在指示圆**两侧水平居中**(横向)或**正下方垂直**(纵向);线两端与圆外缘留 `--spacing-1` (4px) 间隙,避免线穿圆。 + +### 10.2 弹窗步骤条(横向 · 描边圆为主) + +用于 Modal / Drawer 内引导多步操作的紧凑步骤条。指示圆**完成态用描边 + 对勾**而非实心,避免在弹窗背景上过于抢眼。 + +#### 10.2.1 布局 + +``` +( ✓ ) ┄┄┄┄┄ (●2) ┄┄┄┄┄ ( 3 ) ┄┄┄┄┄ ( 4 ) ┄┄┄┄┄ ( 5 ) + 标题 标题 标题 标题 标题 +(描述) (描述) (描述) (描述) (描述) +``` + +#### 10.2.2 规格 + +| 属性 | 取值 | +|------|------| +| 容器水平 padding | `--spacing-5` (24px) | +| 指示圆 → 标题间距 | `--spacing-2` (8px) | +| 标题 → 描述间距 | `--spacing-1` (4px) | +| 节点之间水平间距 | 节点间距自适应等分;连接线占用中间空间 | +| 连接线 | `1px dashed`,已完成段 `#1677FF`,未完成段 `#4A6491` | +| 文字对齐 | 标题、描述居中对齐于指示圆下方 | + +#### 10.2.3 两种变体 + +| 变体 | 适用 | +|------|------| +| **仅标题** | 步骤含义已经清晰(如「填写信息 / 确认 / 完成」),节省垂直空间 | +| **标题 + 描述** | 步骤名抽象需要一行小字补充时使用 | + +### 10.3 页面步骤条(横向 · 实心圆 + 自定义首项标题) + +用于页面级流程(如「新建集群」类向导)。和弹窗步骤条相比,**已完成态指示圆改为实心填充 + 白色对勾**,视觉权重更高;首步标题可显示具体名称(如「新建集群」)而非「标题」占位。 + +#### 10.3.1 布局 + +``` +(✓) ────── (●2) ┄┄┄┄┄ ( 3 ) ┄┄┄┄┄ ( 4 ) ┄┄┄┄┄ ( 5 ) +新建集群 标题 标题 标题 标题 +(描述文本) (描述文本) (描述文本) (描述文本) (描述文本) +``` + +#### 10.3.2 与 §10.2 弹窗步骤条的差异 + +| 维度 | 弹窗步骤条 | 页面步骤条 | +|------|-----------|-----------| +| 完成态圆形 | 描边圆 + 蓝色对勾 | 实心蓝圆 + 白色对勾 | +| 完成段连接线样式 | `1px dashed #1677FF` | `1px solid #1677FF` | +| 标题文字号 | `14px Medium` | `14px Medium`(同) | +| 第一项 | 通用「标题」占位 | 可显示具体名称(自定义文案) | + +#### 10.3.3 完成态指示圆补充 + +| 角色 | 色值 | 来源 | +|------|------|------| +| 已完成圆背景 | `#1677FF` | ZS-002-06 | +| 已完成圆内对勾 | `#FFFFFF` | — | +| 已完成圆边框 | 无(实心) | — | + +### 10.4 纵向步骤条 + +用于侧栏 / 详情页内的纵向流程指引。两种变体——「仅标题」与「标题 + 描述」。 + +#### 10.4.1 布局 + +``` +仅标题: 标题 + 描述: + +(✓) 新建集群 (✓) 新建集群 + ┊ 描述文本描述 +(●2) 标题 ┊ + ┊ (●2) 标题 +( 3 ) 标题 描述文本描述 + ┊ ┊ +( 4 ) 标题 ( 3 ) 标题 + 描述文本描述 + ┊ + ( 4 ) 标题 + 描述文本描述 +``` + +#### 10.4.2 规格 + +| 属性 | 取值 | +|------|------| +| 指示圆 → 标题水平间距 | `--spacing-2` (8px) | +| 节点垂直间距(仅标题) | `--spacing-5` (24px),圆心到圆心 | +| 节点垂直间距(标题 + 描述) | `--spacing-6` (32px),圆心到圆心 | +| 标题 → 描述间距 | `--spacing-1` (4px) | +| 连接线位置 | 上下两个指示圆**正下方垂直**,居中于圆心 | +| 连接线样式 | `1px dashed`,已完成段 `#1677FF`,未完成段 `#4A6491` | +| 文字左对齐 | 标题与描述左对齐,与指示圆右侧对齐基线 | + +#### 10.4.3 与横向步骤条共用规则 + +- 指示圆三态规格沿用 §10.1 +- 已完成态默认用**描边 + 对勾**(与 §10.2 弹窗一致);若纵向步骤条用于页面级主流程,可切换为 §10.3 的实心圆变体 + +### 10.5 交互与动画 + +| 行为 | 动效 | +|------|------| +| 步骤切换(next) | 当前圆 `background: transparent → #1677FF`,`200ms ease-out`;同时上一步圆 `border-color: #4A6491 → #1677FF`,再用 `200ms` 切换为对勾图标淡入 | +| 步骤切换(prev / 回退) | 反向,未完成态恢复 `#4A6491` 边框,圆内数字淡入 | +| 连接线高亮 | 完成段 `border-color: #4A6491 → #1677FF`,`240ms ease-out` | +| Hover 节点(可点击) | 圆边框色暂时变 `#4096FF` (ZS-002-05),标题文字 `#4096FF`,`120ms ease-out`;不可点击节点(未来步骤)无 hover | +| 点击节点 | 节点必须**已完成**才能点(回看),否则视为禁用;点击合法节点触发 `step:change` 事件 | + +### 10.6 状态机与可达性 + +| 当前步 = N | 节点 i 的状态 | +|-----------|----------------| +| `i < N` | Completed(已完成,可点回看) | +| `i === N` | Current(当前,不可点) | +| `i > N` | Upcoming(未开始,不可点) | +| `i.disabled` | Disabled(被显式禁用,例如条件未满足) | + +**键盘导航**: +- `Tab` 顺序在「已完成」节点之间流转,跳过 `Current / Upcoming / Disabled` +- 焦点节点圆外加 `2px solid #4096FF` 焦点环(`outline`,`offset 2px`) + +**屏幕阅读器**: +- 指示圆 `role="img"`,`aria-label="已完成第 N 步"` / `"当前第 N 步"` / `"未开始第 N 步"` +- 容器 `role="list"`,节点 `role="listitem"` +- 当前节点额外 `aria-current="step"` + +### 10.7 CSS Token 参考 + +```css +:root { + /* 步骤条节点 */ + --step-circle-size: 24px; + --step-circle-current-bg: #1677FF; /* ZS-002-06 */ + --step-circle-current-text: #FFFFFF; + --step-circle-done-border: #1677FF; /* ZS-002-06 */ + --step-circle-done-icon: #1677FF; /* 弹窗变体 */ + --step-circle-done-bg-page: #1677FF; /* 页面变体:实心 */ + --step-circle-done-icon-page: #FFFFFF; + --step-circle-upcoming-border: #4A6491; /* ZS-004-05 */ + --step-circle-upcoming-text: #6E88B0; /* ZS-004-04 */ + --step-circle-disabled-border: #2D4473; /* ZS-004-06 */ + --step-circle-disabled-text: #4A6491; /* ZS-004-05 */ + + /* 连接线 */ + --step-line-done: #1677FF; /* 已完成段 */ + --step-line-upcoming: #4A6491; /* 未完成段:ZS-004-05 */ + + /* 文字 */ + --step-title-active: #FFFFFF; + --step-title-inactive: #6E88B0; /* ZS-004-04 */ + --step-desc-active: #C5CFE0; /* ZS-004-02 */ + --step-desc-inactive: #6E88B0; /* ZS-004-04 */ +} +``` + +### 10.8 三种步骤条的选用决策 + +| 场景 | 选择 | +|------|------| +| Modal / Drawer 内多步表单 | §10.2 弹窗步骤条(描边圆,紧凑) | +| 页面级向导(独占大区域) | §10.3 页面步骤条(实心圆,视觉重) | +| 侧栏 / 详情页纵向流程 | §10.4 纵向步骤条 | +| 步骤数 ≤ 3 且无描述 | 优先「仅标题」变体 | +| 步骤名抽象需补充说明 | 用「标题 + 描述」变体 | +| 步骤数 > 7 | 不推荐用步骤条,改用「锚点」(§9.2)或分页 | + +--- + +## 11. 面包屑 + +> 对应「4.3 导航组件-面包屑」。用于显示当前位置在整个站点层级中的路径,配合「← 返回」按钮支持逐级回溯。颜色取自 ZS-002 主色蓝与 ZS-004 深蓝系。 + +### 11.1 标准形态 + +容器顶部带一个独立的容器 / 边框包裹: + +``` +🏠 | 概况 / 总概况 +``` + +带「返回」按钮的层级形态(一级到四级): + +``` +一级: ← 返回 │ 现在位置 +二级: ← 返回 │ 上一级位置 / 现在位置 +三级: ← 返回 │ 上二级位置 / 上一级位置 / 现在位置 +四级: ← 返回 │ 上三级位置 / 上二级位置 / 上一级位置 / 现在位置 +``` + +### 11.2 规格 + +| 属性 | 取值 | +|------|------| +| 容器高度 | `40px` | +| 容器背景 | `#0C1836` (ZS-004-09) | +| 容器底边 | `1px solid #1A2F5A` (ZS-004-07) | +| 容器水平 padding | `--spacing-4` (16px) | +| 字号 | `14px Regular` | +| 元素间距(图标 / 文字 / 分隔符之间) | `--spacing-2` (8px) | +| 「返回」与首段路径之间分隔符 `│` | `1px solid #4A6491`,高度 `14px` | + +### 11.3 配色 + +| 角色 | 色值 | 来源 | 备注 | +|------|------|------|------| +| Home 图标 🏠 | `#4096FF` | ZS-002-05 | 默认主色,hover → `#1677FF` (ZS-002-06) | +| 「← 返回」图标 + 文字 | `#4096FF` | ZS-002-05 | 链接样式,hover 显示下划线 | +| 历史路径文字(可点击) | `#4096FF` | ZS-002-05 | 同链接配色 | +| 当前位置文字(最后一段) | `#FFFFFF` | — | 加粗 `14px Medium`,不可点击 | +| 分隔符 `/` | `#4A6491` | ZS-004-05 | 静态、不可点 | +| 分隔符 `│`(返回按钮与路径之间) | `#4A6491` | ZS-004-05 | 同上 | +| Hover 路径项 | `#1677FF` | ZS-002-06 | 文字色 + 下划线 | +| 失效路径项(无对应页) | `#4A6491` | ZS-004-05 | `cursor: not-allowed` | + +### 11.4 交互规则 + +- **点击「← 返回」**:逐步向上返回一级(等同浏览器 back) +- **点击文字路径**:快速跳回到对应任意上级页面 +- **当前位置**(最后一段):不响应点击,仅作展示 +- **层级建议**:尽可能不超过 **4 级**;超过 4 级会让面包屑过长,应改用侧栏 / 锚点辅助 + +### 11.5 与导航的关系 + +面包屑**不替代**主导航(§8): + +- 主导航 = 「我能去哪些地方」(站点结构) +- 面包屑 = 「我当前在哪里」(路径回溯) + +两者并存时,面包屑紧贴页面主内容区顶部,位于主导航下方、页头(§12)上方。 + +### 11.6 CSS 参考 + +```css +.breadcrumb { + display: flex; + align-items: center; + height: 40px; + padding: 0 var(--spacing-4); + background: #0C1836; + border-bottom: 1px solid #1A2F5A; + font-size: 14px; + gap: var(--spacing-2); +} +.breadcrumb-back { color: #4096FF; cursor: pointer; } +.breadcrumb-back:hover { color: #1677FF; text-decoration: underline; } +.breadcrumb-sep-bar { width: 1px; height: 14px; background: #4A6491; } +.breadcrumb-sep { color: #4A6491; } +.breadcrumb-link { color: #4096FF; cursor: pointer; } +.breadcrumb-link:hover { color: #1677FF; text-decoration: underline; } +.breadcrumb-current { color: #FFFFFF; font-weight: 500; } +``` + +--- + +## 12. 页头 + +> 对应「4.6 导航组件-分页」上半段示意。用于页面主内容区顶部、面包屑下方,承载页面标题与可选的说明 / Tabs。共有 4 种变体。 + +### 12.1 通用规格 + +| 属性 | 取值 | +|------|------| +| 容器高度 | `56px`(仅标题)/ `64px`(含 Tabs) | +| 背景 | `#0C1836` (ZS-004-09) | +| 底边 | `1px solid #1A2F5A` (ZS-004-07) | +| 水平 padding | `--spacing-5` (24px) | +| 文字基线 | 垂直居中 | + +### 12.2 四种变体 + +#### 12.2.1 基础页头 + +只有标题: + +``` +页头标题 +``` + +| 角色 | 色值 / 字号 | 来源 | +|------|-------------|------| +| 标题 | `18px Bold`,`#FFFFFF` | — | + +#### 12.2.2 页头 + 说明 + +标题后跟一段灰色说明文字: + +``` +页头标题 (这是一句简要说明) +``` + +| 角色 | 色值 / 字号 | 来源 | +|------|-------------|------| +| 标题 | `18px Bold`,`#FFFFFF` | — | +| 说明文字 | `14px Regular`,`#6E88B0` | ZS-004-04 | +| 标题 → 说明间距 | `--spacing-3` (12px) | — | + +说明用半角圆括号包裹,颜色与括号一致(不要让括号比文字更醒目)。 + +#### 12.2.3 页头 + 说明 icon + +标题后跟一个可悬停查看说明的图标: + +``` +页头标题 ⓘ +``` + +| 角色 | 色值 / 字号 | 来源 | +|------|-------------|------| +| 标题 | `18px Bold`,`#FFFFFF` | — | +| 说明 icon `ⓘ` 或 `?` | `16px`,`#6E88B0` | ZS-004-04 | +| Icon hover | `#4096FF` | ZS-002-05 | +| 标题 → 图标间距 | `--spacing-2` (8px) | — | + +Hover 弹出 Tooltip(沿用 §9.3.3 Tooltip 配色),承载完整说明文字。 + +#### 12.2.4 分 Tabs 页头 + +标题位置直接放横向页签(沿用 §9.1.1 横向页签配色),点击切换右侧主内容区: + +``` +标题 标题 标题 +───── +``` + +| 复用规则 | 说明 | +|----------|------| +| Tab 配色 | 完全沿用 §9.1.1 横向页签 | +| Tab 字号 | `16px Medium`(页头位置比一般 tab 略大) | +| Tab 间距 | `--spacing-5` (24px) | +| 激活态下划线 | `2px solid #4096FF`,宽与文字齐 | + +### 12.3 与右侧操作区的组合 + +页头右侧常承载主操作按钮(「+ 新建」、「导出」等)。规则: + +- 按钮组靠右对齐,右 padding 与左 padding 一致 (`--spacing-5`) +- 多按钮排列顺序参照 §5.3.4(默认按钮在左、主按钮在右),按钮间距 `--spacing-2` +- Tabs 页头中右侧操作区与 Tabs 同高居中 + +--- + +## 13. 分页 + +> 对应「4.6 导航组件-分页」下半段。三种形态——**正常分页 / 精简分页 / 导航式分页**——按页面信息量与可用空间选用。 + +### 13.1 正常分页 + +承载大列表,提供页码、跳转、每页条数选择: + +``` +共 240 项 < ① 2 3 4 5 … 24 > 每页 [10▾] 条 前往 [ ] 页 +``` + +#### 13.1.1 元素配色与规格 + +| 元素 | 字号 / 色值 | 来源 / 说明 | +|------|-------------|-------------| +| 「共 N 项」总数文字 | `14px Regular`,`#6E88B0` | ZS-004-04 | +| 页码按钮(默认) | `14px Regular`,`#C5CFE0`,背景 `transparent` | ZS-004-02 | +| 页码按钮(hover) | `#4096FF`,背景 `rgba(22,119,255,0.08)` | ZS-002-05 | +| 页码按钮(激活/当前页) | `#FFFFFF`,背景 `#1677FF`(实心圆) | ZS-002-06 | +| 页码按钮(失效) | `#4A6491`,背景 `transparent` | ZS-004-05 | +| 左右翻页箭头 `<` `>` | `14px`,`#6E88B0`,hover → `#4096FF` | ZS-004-04 → ZS-002-05 | +| 翻页箭头失效(首/末页) | `#4A6491` | ZS-004-05 | +| 省略号 `…` | `#6E88B0` | ZS-004-04,静态展示,不可点 | +| 每页条数下拉 `[10▾]` | 沿用 §14.1 输入框规格 | 高度 `28px`,宽 `60px` | +| 「前往 N 页」输入框 | 沿用 §14.1 输入框规格 | 高度 `28px`,宽 `48px` | + +#### 13.1.2 页码按钮规格 + +| 属性 | 取值 | +|------|------| +| 形状 | 圆形 `28px × 28px`(`border-radius: 50%`) | +| 默认背景 | `transparent` | +| 当前页背景 | `#1677FF` (ZS-002-06),实心圆,白色数字 | +| 字号 | `14px Regular`(数字 D-DIN 或 Noto Sans) | +| 项间距 | `--spacing-1` (4px) | + +#### 13.1.3 元素间距 + +| 区段 | 间距 | +|------|------| +| 「共 N 项」→ 翻页区 | `--spacing-4` (16px) | +| 翻页区内(箭头 / 页码) | `--spacing-1` (4px) | +| 翻页区 → 每页下拉 | `--spacing-5` (24px) | +| 每页下拉 → 前往输入 | `--spacing-4` (16px) | + +### 13.2 精简分页 + +承载弹窗 / 表格紧凑场景,只暴露「当前页 / 总页数」与左右翻页: + +``` +共 1 项 < [ 1 ] / 10 > +``` + +#### 13.2.1 规格 + +| 元素 | 配色 / 备注 | +|------|------------| +| 「共 N 项」 | 同 §13.1.1 | +| `<` `>` 翻页箭头 | 同 §13.1.1 | +| `[ 1 ]` 当前页输入框 | 沿用 §14.1 输入框规格,高 `28px`,宽 `40px`,居中文字 | +| `/ N` 总页数 | `14px Regular`,`#6E88B0` (ZS-004-04) | +| 元素间距 | `--spacing-2` (8px) | + +#### 13.2.2 与正常分页的差异 + +| 维度 | 正常分页 | 精简分页 | +|------|----------|----------| +| 页码 | 点击式数字列表 | 输入框 + 总页数文字 | +| 跳转方式 | 点击数字 / 前往输入 | 直接修改输入框 | +| 每页条数 | 暴露下拉 | 不暴露(默认值由调用方决定) | +| 适用 | 列表 / 表格主内容区 | 弹窗、抽屉、卡片内嵌列表 | + +### 13.3 导航式分页 + +承载长文 / 详情页的「上一篇 / 下一篇」式翻页,只有方向,不暴露页码: + +``` +下一页 » +« 上一页 +``` + +#### 13.3.1 规格 + +| 元素 | 配色 / 备注 | +|------|------------| +| 「下一页」/「上一页」文字 + 箭头 | `14px Regular`,`#4096FF` (ZS-002-05) | +| Hover | `#1677FF` (ZS-002-06) + 下划线 | +| 失效(首/末篇) | `#4A6491` (ZS-004-05),`cursor: not-allowed` | +| 箭头位置 | `下一页 »`(箭头在后)/ `« 上一页`(箭头在前) | +| 文字 → 箭头间距 | `--spacing-2` (8px) | +| 两个按钮纵向排列时项间距 | `--spacing-3` (12px) | + +### 13.4 三种分页的选用决策 + +| 场景 | 选择 | +|------|------| +| 后台列表 / 表格 | §13.1 正常分页 | +| Modal / Drawer 内嵌列表 | §13.2 精简分页 | +| 详情页前后翻 | §13.3 导航式分页 | +| 列表少于 1 页(项数 ≤ pageSize) | **不显示**分页 | +| 项数明确但页码可能很多(>50 页) | 用正常分页 + 隐藏中间页码(`...` 省略) | + +--- + +## 14. 输入框(搜索框) + +> 对应设计稿「输入框.png」。按使用场景分三类:**基本搜索框 / 后台系统搜索框 / 页面头部搜索框**。配色全部从 ZS-002 主色蓝与 ZS-004 深蓝系取。 + +### 14.1 公共规格 + +所有搜索框共用同一套尺寸 / 边框规格: + +| 属性 | 默认(正常) | 小号 | +|------|--------------|------| +| 高度 | `32px` | `28px` | +| 水平 padding | `--spacing-3` (12px) | `--spacing-2` (8px) | +| 圆角 | `2px`(§4.1) | `2px` | +| 字号 | `14px Regular` | `14px Regular` | +| 文字色 | `#C5CFE0` (ZS-004-02) | 同 | +| 占位符色 | `#6E88B0` (ZS-004-04) | 同 | +| 背景 | `#0C1836` (ZS-004-09) | 同 | + +#### 14.1.1 边框状态 + +| 状态 | 边框 | 来源 | +|------|------|------| +| 默认 | `1px solid #1A2F5A` | ZS-004-07 | +| Hover | `1px solid #4096FF` | ZS-002-05 | +| Focus | `1px solid #1677FF` + 外发光 `0 0 0 2px rgba(22,119,255,0.16)` | ZS-002-06 | +| 失效 | `1px solid #2D4473`,背景 `rgba(22,119,255,0.04)` | ZS-004-06 | +| 错误 | `1px solid #F5222D` + 外发光 `0 0 0 2px rgba(245,34,45,0.16)` | ZS-001-06 | + +### 14.2 基本搜索框 + +#### 14.2.1 基础搜索框 + +输入框尾部带 🔍 图标,回车或点击图标触发搜索: + +``` +[ 搜索 🔍 ] +``` + +| 角色 | 色值 / 规格 | 来源 | +|------|-------------|------| +| 搜索图标 🔍 | `16px`,`#6E88B0` | ZS-004-04 | +| 图标 hover | `#4096FF` | ZS-002-05 | +| 图标 → 输入边缘间距 | `--spacing-2` (8px) | — | + +#### 14.2.2 分类搜索框(带前缀下拉) + +输入框左侧拼一个分类下拉,常用于「按协议搜 IP」等场景: + +``` +[ http:// ▾ ] [ 搜索 IP 地址 🔍 ] +``` + +| 角色 | 色值 / 规格 | 备注 | +|------|-------------|------| +| 前缀下拉宽度 | `auto`,最小 `72px` | 内容自适应 | +| 前缀下拉与输入框 | 共用同一组边框,**中间无缝**(公用一条 `1px #1A2F5A` 分隔) | 视为一体控件 | +| 前缀下拉箭头 `▾` | `12px`,`#6E88B0` | hover → `#4096FF` | +| 整体 focus 行为 | 焦点落到输入框时,**前缀下拉的边框也同步高亮** | — | + +### 14.3 后台系统常用搜索框 + +输入框 + 紧贴右侧的**实心蓝色搜索按钮**,常用于后台主内容区列表筛选: + +``` +[ 🔍 搜索任务集名称/负责人 ] [ 搜索 ] +``` + +#### 14.3.1 规格 + +| 元素 | 色值 / 规格 | 来源 | +|------|-------------|------| +| 左侧搜索图标 🔍 | `16px`,`#6E88B0` | ZS-004-04 | +| 图标 → 文字间距 | `--spacing-2` (8px) | — | +| 输入框右边框 | **无**(与按钮无缝拼接) | — | +| 按钮 | 沿用 §5.2.1 主按钮,圆角 `2px`,文字 `搜索` | — | +| 按钮高度 | 与输入框一致 `32px` | — | +| 按钮左侧 | 与输入框紧贴,**无间距** | — | + +### 14.4 页面头部搜索框 + +页头位置的搜索控件,支持四种扩展:**单行 / 高级搜索 / 历史记录 / 输入联想**。 + +#### 14.4.1 单行(最简) + +``` +[ 输入的文字 ] [ 搜索 ] +``` + +规格同 §14.3,仅省略左侧 🔍 图标,让输入文字占满。 + +#### 14.4.2 含高级搜索切换 + +右侧叠加一个文字按钮「高级搜索 ▾」,展开后显示模式选项: + +``` +[ 输入的文字 ] [ 搜索 ] 高级搜索 ▾ + ○ 模糊匹配 ● 精准匹配 +``` + +| 元素 | 色值 / 规格 | 来源 | +|------|-------------|------| +| 「高级搜索 ▾」文字按钮 | `14px Regular`,`#4096FF`,hover `#1677FF` | ZS-002-05 → ZS-002-06 | +| 「高级搜索」与「搜索」按钮间距 | `--spacing-3` (12px) | — | +| 单选项「模糊匹配 / 精准匹配」 | 沿用通用 Radio 配色:未选 `1px solid #6E88B0`,已选实心 `#1677FF` | — | +| 单选项 → 搜索框纵向间距 | `--spacing-2` (8px) | — | + +#### 14.4.3 含历史记录 + +输入框下方显示最近搜索词列表,点击直接用该词搜索: + +``` +[ 输入的文字 ] [ 搜索 ] + +搜索记录词、搜索记录词、记录词 +``` + +| 元素 | 色值 / 规格 | 来源 | +|------|-------------|------| +| 历史记录文字 | `12px Regular`,`#4096FF` | ZS-002-05 | +| 项间分隔符(顿号 `、`) | `#4A6491` | ZS-004-05(与文字色不同,弱化分隔) | +| 历史项 hover | `#1677FF` + 下划线 | ZS-002-06 | +| 历史区与搜索框纵向间距 | `--spacing-2` (8px) | — | + +#### 14.4.4 含输入联想下拉 + +用户输入时,下方弹出联想词面板: + +``` +[ 这是 ] [ 搜索 ] +┌─────────────────────────────────┐ +│ 这是搜索输入联想词 │ +│ 这是搜索输入联想词 │ +│ 这是搜索输入联想词 │ +│ 这是联想词 │ +│ 这是联想词悬浮 ←hovered │ +│ …… │ +└─────────────────────────────────┘ +``` + +##### 面板规格 + +| 属性 | 取值 | +|------|------| +| 背景 | `#122347` (ZS-004-08) | +| 边框 | `1px solid #1A2F5A` (ZS-004-07) | +| 阴影 | `0 4px 16px rgba(6, 14, 36, 0.6)`(§3.1.4 三级阴影变体) | +| 圆角 | `2px`(与输入框一致) | +| 项高度 | `36px` | +| 项水平 padding | `--spacing-3` (12px) | +| 项字号 | `14px Regular`,`#C5CFE0` (ZS-004-02) | +| 面板宽度 | 与输入框等宽(不含右侧按钮) | +| 面板与输入框纵向间距 | `--spacing-1` (4px) | + +##### 项状态 + +| 状态 | 背景 | 文字 | +|------|------|------| +| 默认 | `transparent` | `#C5CFE0` | +| Hover(鼠标 / 键盘焦点) | `rgba(22,119,255,0.16)` 或 `#1A2F5A` (ZS-004-07) | `#FFFFFF` | +| 已点击(点选后回填输入框) | — | — | + +##### 键盘导航 + +- `↑` / `↓` 在联想词之间移动焦点(焦点项视觉等价于 Hover) +- `Enter` 选中焦点项并回填输入框 + 触发搜索 +- `Esc` 关闭面板,焦点回到输入框 + +#### 14.4.5 联想下拉 + 历史记录的关系 + +- **未输入时**:可显示历史记录(§14.4.3 横向展开形态) +- **开始输入时**:历史记录被联想下拉**取代**,不要并存 +- **清空输入框**:回到历史记录显示 + +### 14.5 三种搜索框的选用决策 + +| 场景 | 选择 | +|------|------| +| 表单内 / 卡片内的辅助搜索 | §14.2.1 基础搜索框(图标内嵌) | +| 需要按类型 / 协议过滤的搜索 | §14.2.2 分类搜索框(带前缀下拉) | +| 后台列表 / 表格顶部筛选 | §14.3 后台搜索框(输入 + 实心按钮) | +| 页面头部全局搜索 | §14.4 页面头部搜索框(按需开启高级 / 历史 / 联想) | + +### 14.6 CSS Token 参考 + +```css +:root { + --search-bg: #0C1836; /* ZS-004-09 */ + --search-border: #1A2F5A; /* ZS-004-07 */ + --search-border-hover: #4096FF; /* ZS-002-05 */ + --search-border-focus: #1677FF; /* ZS-002-06 */ + --search-border-error: #F5222D; /* ZS-001-06 */ + --search-text: #C5CFE0; /* ZS-004-02 */ + --search-placeholder: #6E88B0; /* ZS-004-04 */ + --search-icon: #6E88B0; + --search-icon-hover: #4096FF; + + /* 联想下拉 */ + --suggest-bg: #122347; /* ZS-004-08 */ + --suggest-item-hover: rgba(22,119,255,0.16); + --suggest-item-text: #C5CFE0; + --suggest-item-hover-text: #FFFFFF; + + /* 历史记录 */ + --history-text: #4096FF; + --history-text-hover: #1677FF; + --history-sep: #4A6491; +} +``` From cf8c17d08b960b5314ea6848302e43cb2bf1ce8f Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 26 May 2026 09:15:32 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E6=95=8F=E6=84=9F=E8=AF=8D=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/workspace/workspace-nav-chat-list.tsx | 4 ++-- frontend-web/src/lib/desensitize.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 99f69bf8..24b099af 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -119,7 +119,7 @@ export function WorkspaceNavChatList({ - + {/* 多智能体规划 - + */} = { 模拟: "MN", 推演: "TY", 认知: "RZ", - 触及: "CJ" + 触及: "CJ", + 军: "J", + 美军: "MJ", + 台军: "TJ", }; /** From 6d02e2c89e972a1952cddcd94f44cb3e77095f27 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 26 May 2026 09:50:24 +0800 Subject: [PATCH 07/10] =?UTF-8?q?ai=E5=86=99=E4=BD=9C=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/core/config/index.ts | 2 +- .../components/ai-writing/AIWritingForm.tsx | 39 ++----------------- .../src/open-canvas/types/ai-writing.ts | 2 +- 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 1eb5cc28..822d9869 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -5,7 +5,7 @@ import { env } from "@/env"; * 素材的 recUuid 直接拼接在其后即为完整原文链接。 */ export const INTRANET_SOURCE_URL_BASE = - "https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid="; + "https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid-detail?recUuid="; /** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */ export function buildIntranetSourceUrl(recUuid?: string | null): string { diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index 63ef8472..dafe4a4b 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -19,7 +19,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes'; interface AIWritingFormProps { onSubmit: (req: AIWritingRequest) => void; disabled?: boolean; - /** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */ + /** workspace 入口:隐藏严格模式、目标读者 */ hideAdvancedFields?: boolean; } @@ -33,7 +33,7 @@ const INPUT_CLASS = 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' + 'focus-visible:ring-ring disabled:opacity-50'; -const DEFAULT_KEYWORD_COUNT = 6; +const DEFAULT_KEYWORD_COUNT = 1; const DEFAULT_AUDIENCE = '普通读者'; const MAX_INTENT_LENGTH = 500; @@ -50,7 +50,6 @@ export function AIWritingForm({ const [articleType, setArticleType] = useState('analysis'); const [audience, setAudience] = useState('普通读者'); const [wordCountInput, setWordCountInput] = useState('800'); - const [keywordCountInput, setKeywordCountInput] = useState('1'); const [strictMode, setStrictMode] = useState(false); const [modelName, setModelName] = useState(''); const [models, setModels] = useState([]); @@ -110,23 +109,10 @@ export function AIWritingForm({ } } - // 检索关键词数量限定 1~10,非法值回退默认 6 - function handleKeywordCountBlur() { - const num = parseInt(keywordCountInput, 10); - if (isNaN(num)) { - setKeywordCountInput('1'); - } else { - setKeywordCountInput(String(Math.max(1, Math.min(10, num)))); - } - } - function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!intent.trim()) return; const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800); - const keywordCount = hideAdvancedFields - ? DEFAULT_KEYWORD_COUNT - : Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT)); onSubmit({ userIntent: intent.trim(), articleType, @@ -134,7 +120,7 @@ export function AIWritingForm({ ? DEFAULT_AUDIENCE : audience.trim() || DEFAULT_AUDIENCE, wordCountTarget: wordCount, - keywordCount, + keywordCount: DEFAULT_KEYWORD_COUNT, writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', modelName: modelName || undefined, }); @@ -235,25 +221,6 @@ export function AIWritingForm({ {!hideAdvancedFields && ( <> -
- - setKeywordCountInput(e.target.value)} - onBlur={handleKeywordCountBlur} - disabled={disabled} - /> -

- 素材收集专家用几个关键词检索,越多素材越全面但耗时更长。 -

-
-
Date: Tue, 26 May 2026 11:47:47 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/docs/multi-agent-backend-dev.md | 29 +++++---- frontend-web/docs/multi-agent-frontend-dev.md | 16 ++--- .../backend/app/gateway/routers/intent.py | 18 +++--- .../app/gateway/routers/multi_agent.py | 61 +++++++++++++------ .../backend/app/gateway/routers/recommend.py | 9 ++- 5 files changed, 81 insertions(+), 52 deletions(-) diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index c4724d83..e759d19d 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -10,8 +10,8 @@ ## 0. 速读 -- 三个圆桌 Router(`intent / recommend / multi_agent`)**全部走 HTTP loopback**:在同进程内通过 `httpx.AsyncClient` 调本机 `127.0.0.1` 的 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream`。 -- **所有 loopback `httpx.AsyncClient` 必须 `trust_env=False`**。开发机若设了 `HTTP_PROXY` / `HTTPS_PROXY`,httpx 默认会把 loopback 也走代理 → 不可达 → 单次请求多出 40 秒超时(典型症状日志:`headers_in=42.160s`)。 +- 三个圆桌 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。 - **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。 @@ -59,7 +59,7 @@ Step 3 最终方案展示 | 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 | | 3 | 3 | `artifacts.py`(通用) | 无新 agent | -所有接口需 `Authorization: Bearer `。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:`headers_in=40s+`,前端表现为「Step 1 init 卡顿 / Step 2 init 永远不发 seat_ready」)。 +所有接口需 `Authorization: Bearer `。Loopback 调用一律由 `_make_loopback_client(request)` 构造(`httpx.ASGITransport(app=request.app)`,base_url 占位 `http://loopback`),**不经 TCP**,因此不再受本机 `HTTP_PROXY` 干扰。 --- @@ -499,7 +499,7 @@ async def _broadcast(client, base, headers, message, agent_threads, skip): (None, captured) — 流结束,captured 是所有 data: 行的字符串列表 ``` -日志 `[upstream-timing] headers_in=<>` / `first_line=<>`:排查首 token 慢、代理导致 loopback 超时(见 `trust_env=False`)。 +日志 `[upstream-timing] headers_in=<>` / `first_line=<>`:排查上游首字节延迟;改 ASGITransport 后通常 `headers_in` < 10ms,长尾大概率出在路由 handler 内部(不再是网络/代理问题)。 `_parse_last_messages(captured)` 从倒数往前扫,**跳过 trailing `data: null` end 帧**,找第一条解析为 dict 且含 `messages` key 的 frame;取 `messages` 列表。无可解析帧抛 `502`。 @@ -593,14 +593,21 @@ Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力 ## 9. 关键设计决策 -### 9.1 HTTP loopback + `trust_env=False` +### 9.1 ASGITransport 进程内 loopback(替代旧的 HTTP loopback) -三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。 +三个 router 通过 `httpx.ASGITransport(app=request.app)` 直调本机 ASGI app,**不走 TCP / 不开 socket / 不经网络栈**,但仍然完整复用 FastAPI 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应。代价仅是路由层的少量 dispatch 开销(< 1ms),相对 LLM run 本身的数百 ms~数十秒可忽略。 -**每个 `httpx.AsyncClient(timeout=None, trust_env=False)` 都是必须的**: +统一入口:`multi_agent._make_loopback_client(request)`,返回: -- `timeout=None` — SSE 长连接需要 -- `trust_env=False` — 否则 httpx 会读取 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量,把 loopback `127.0.0.1` 也走代理。开发机常配的「校园网代理 / 公司代理」会把回环地址丢弃,导致单次请求 40+ 秒超时(日志典型症状:`headers_in=42.160s`) +```python +httpx.AsyncClient( + transport=httpx.ASGITransport(app=request.app), + base_url="http://loopback", # 占位 host,ASGITransport 不在意 + timeout=None, # SSE 长连接仍需 None +) +``` + +**已废止的 `trust_env=False` workaround**:旧版本走 `127.0.0.1:` 真实 HTTP,需要 `trust_env=False` 阻止 httpx 读 `HTTP_PROXY` 把回环也走代理(症状日志 `headers_in=42.160s`)。改 ASGITransport 后这个问题在物理上已不存在——根本没走网络栈。 ### 9.2 协调智能体每次新建 @@ -1105,7 +1112,7 @@ curl -X POST http://127.0.0.1:8001/api/recommend/stream \ | Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY(§4.3 顺序) | | 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple`(缺省 false) | | Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 / `_validate_agent_id` 命中 | Network + 后端 `multi_agent` 日志 | -| Step 2 init 完全卡住 ~40 秒 | `httpx` 走了 `HTTP_PROXY` | 确认 `trust_env=False` 仍在;grep `headers_in=` 看时长 | +| Step 2 init 完全卡住(历史症状,ASGITransport 改造后已不应再出现) | 旧版 `httpx` 走 `HTTP_PROXY` 拦回环 → 40s 超时 | 自改 ASGITransport 起此现象物理上不可能复现;若仍卡,看后端日志栈、检查路由 handler 是否阻塞 | | leader 立即 `status: []` | 未调用 orchestration 技能 / SkillStopMiddleware 没拦到 / SOUL 没声明派活 | 换模型或加强 SOUL;检查 `skill_stop_names` 是否包含 `agent_orchestration` | | dispatch 失败但 leader 文本看着正常 | 模型把 `roundtable-x(中文)` 整段塞进 `agent_name` | `_strip_display_suffix` 会兜底;若仍报 400,强化 SOUL 的「严格英文 id」 | | 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧;检查 `candidate_ids` 集合 | @@ -1122,7 +1129,7 @@ grep "dispatch failed" .runtime/logs/*.log grep "upstream-timing" .runtime/logs/*.log grep "leader-timing" .runtime/logs/*.log grep "special-timing" .runtime/logs/*.log -grep "headers_in=" .runtime/logs/*.log # 走代理症状 +grep "headers_in=" .runtime/logs/*.log # 上游首字节延迟;ASGITransport 改造后通常 < 10ms ``` --- diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index 6b7de99d..fe1c781d 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -124,17 +124,17 @@ frontend-web/src/roundtable-planning/ [进入 Step 3] HighFidelityReport - consensusText ← lastLeaderContent - seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") - Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) - 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) -``` + consensusText ← lastLeaderContent + seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") + Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) + 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) + ``` ---- + --- -## 4. API 层 + ## 4. API 层 -### 4.1 `api/intent.ts` — Step 1 + ### 4.1 `api/intent.ts` — Step 1 | 函数 | 说明 | |------|------| diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 425b24db..5a83873c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -71,6 +71,7 @@ from app.gateway.routers.multi_agent import ( _find_trailing_ai_text, _flatten_content, _loopback_base, + _make_loopback_client, _parse_last_messages, _run_payload, _stream_upstream, @@ -108,13 +109,10 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, """ base = _loopback_base(request) headers = _auth_headers(request) - # trust_env=False: this client only talks to the same-process gateway at - # 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide - # on the dev machine) makes httpx route loopback calls through an upstream - # proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per - # request before falling back. Symptom in logs: `headers_in=42.160s`. + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 try: - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + async with _make_loopback_client(request) as client: thread_id = await _create_thread(client, base, headers) except HTTPException: # 上游已用结构化方式失败,原样传给前端不需要二次包装。 @@ -165,11 +163,9 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: body = _run_payload( agent_name=INTENT_AGENT_ID, diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index ce41e820..27527643 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -97,13 +97,12 @@ def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, A if isinstance(root, httpx.ConnectError): target = str(root).strip().lower() - # loopback 失败 = Gateway 自己没起 / 代理拦回环;outbound 失败 = LLM 提供商不可达。 + # loopback 已改用 ASGITransport(进程内直调),不再经网络;ConnectError 几乎全部来自上游 LLM。 if "127.0.0.1" in target or "localhost" in target or "loopback" in target: code = "loopback_connect_failed" hint = ( - "本地 Gateway loopback 连接失败:确认后端进程在 8001 端口监听;" - "另检查 HTTP_PROXY / HTTPS_PROXY 是否把 127.0.0.1 也走了代理" - "(代码已显式 trust_env=False,理论上不该走代理)。" + "本地 Gateway ASGI loopback 失败:这种情况罕见,通常意味着 ASGI app " + "未正确注入到 request.app,或路由层抛出未捕获异常。请查后端日志栈。" ) else: code = "upstream_connect_failed" @@ -160,12 +159,44 @@ def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, A # --------------------------------------------------------------------------- # Loopback 辅助函数(Gateway → Gateway,同进程) +# +# 通过 httpx ASGITransport 直接调用本进程的 ASGI app,**不走 TCP / 不经过网络栈**: +# - 彻底规避「开发机 HTTP_PROXY 把 loopback 也走代理」导致的 40s 超时 +# - 不再需要 trust_env=False / 真实端口 / 真实 host +# - 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应一切照旧 +# +# 历史背景:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:`, +# 见 git log 与 multi-agent-backend-dev.md §9.1。改用 ASGITransport 后已不需要 +# 防代理 workaround,但 _loopback_base 仍返回一个占位 base_url 以保持原有 helper +# 函数签名(_create_thread / _stream_upstream 等)零改动。 # --------------------------------------------------------------------------- +# ASGITransport 不在意 host,仅作 URL 拼接占位。 +_LOOPBACK_BASE_URL = "http://loopback" + + def _loopback_base(request: Request) -> str: - """当前请求的 scheme+host,用于 httpx 打回本机 Gateway。""" - return f"{request.url.scheme}://{request.url.netloc}" + """返回 loopback base URL 占位串。 + + ASGI transport 模式下不再用真实 scheme+host,但保留此函数以兼容 + `_create_thread / _stream_upstream` 等内部 helper 的 `base` 参数。 + """ + return _LOOPBACK_BASE_URL + + +def _make_loopback_client(request: Request) -> httpx.AsyncClient: + """构造一个走 ASGITransport 的 httpx.AsyncClient,**不走网络栈**。 + + 所有圆桌路由(intent / recommend / multi_agent)的 loopback 调用都走这里, + 替代原先的 `httpx.AsyncClient(timeout=None, trust_env=False)` + 127.0.0.1。 + """ + transport = httpx.ASGITransport(app=request.app) + return httpx.AsyncClient( + transport=transport, + base_url=_LOOPBACK_BASE_URL, + timeout=None, + ) def _auth_headers(request: Request) -> dict[str, str]: @@ -431,11 +462,9 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: # Per-seat prepare task — same body as before, but we no longer # await them with asyncio.gather. Instead, asyncio.as_completed @@ -552,7 +581,7 @@ async def _stream_upstream( - ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表, 供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。 - 日志 ``[upstream-timing]``:排查首 token 慢、代理导致 loopback 超时(见 trust_env=False)。 + 日志 ``[upstream-timing]``:排查首 token 慢;loopback 已走 ASGITransport,不会再有代理超时。 """ url = f"{base}/api/threads/{thread_id}/runs/stream" @@ -968,11 +997,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: if agent_type == "leader": gen = _leader_run( diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommend.py b/offline-backend-20260512/backend/app/gateway/routers/recommend.py index 5035cb4c..45b0cb81 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommend.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommend.py @@ -52,6 +52,7 @@ from app.gateway.routers.multi_agent import ( _create_thread, _flatten_content, _loopback_base, + _make_loopback_client, _parse_last_messages, _run_payload, _stream_upstream, @@ -193,11 +194,9 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: # 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。 thread_id = await _create_thread(client, base, headers) From d1e6cd5a8f530373c1fc021376b94ee194177775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 26 May 2026 13:01:01 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E5=A4=9A=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/gateway/routers/_roundtable_seed.py | 411 ++++++++++++++++++ .../backend/app/gateway/routers/intent.py | 8 + .../backend/app/gateway/routers/recommend.py | 5 + 3 files changed, 424 insertions(+) create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py new file mode 100644 index 00000000..791206e5 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py @@ -0,0 +1,411 @@ +"""圆桌功能型 agent 的「调用前自检 + 缺失自动补建」工具。 + +适用场景 +-------- +``roundtable-intent`` / ``roundtable-recommender`` 是圆桌 Step 1 + 推荐链路的 +**功能型** 内置 agent,任何部署都必须存在。但仓库根 ``.gitignore`` 把整个 +``.deer-flow/`` 目录排除,新机器 ``git clone`` 之后这两个目录**不会存在**, +直接调 ``/api/intent/stream`` 会因为 agent 解析失败抛 500。 + +本模块在三个路由入口(``intent.init``、``intent.stream``、``recommend.stream``) +被调用,检查磁盘文件是否齐全;缺失则用下方嵌入的标准模板写回去。 +``_sync_legacy_agents`` 会在下一次 ``GET /api/agents`` 时把目录 upsert 进 DB, +所以这里只关心磁盘文件,不直接动 DB。 + +为什么嵌入字符串而不是用 importlib.resources 资源文件 +------------------------------------------------------ +SOUL 体量不大(两个加起来 ~200 行),嵌入到 Python 文件里是**单一可信源**——改 +SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配 +``package-data`` 导致部署机拿不到。 +""" + +from __future__ import annotations + +import logging +from typing import Iterable + +import yaml + +from deerflow.config.paths import get_paths + +logger = logging.getLogger(__name__) + + +INTENT_AGENT_ID = "roundtable-intent" +INTENT_AGENT_NAME = "任务理解" +INTENT_AGENT_DESCRIPTION = ( + "任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的" + "「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入" +) + +# 与 .deer-flow/agents/roundtable-intent/SOUL.md 保持完全一致; +# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。 +INTENT_AGENT_SOUL = """# 任务理解智能体 + +你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。 + +--- + +## 1. 你的目标 + +通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用: + +| 字段 | 含义 | +|------|------| +| `objective` | 一句话任务目标 | +| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) | +| `assumptions` | 你替用户做的合理假设(可空数组) | + +**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。 + +--- + +## 2. 工作流(必须遵守) + +``` +用户描述任务 + ↓ +信息够不够? + ├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片) + ↓ +用户回复(可能是「我选择:…」或自由文本) + ↓ +重复 2–5 轮 + ↓ +信息充分 → 自然语言汇总 + [INTENT_READY] + JSON + ↓ +停止追问(本轮及之后都不要再 ask_clarification) +``` + +**关键规则**: +- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。 +- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。 +- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。 + +--- + +## 3. 唯一工具:`ask_clarification` + +当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。 + +### 3.1 参数说明 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `question` | ✅ | 一个聚焦的问题,一次只问一个决策点 | +| `clarification_type` | ✅ | 见下表 | +| `context` | 推荐 | 为什么需要用户确认(1 句话) | +| `options` | 有离散选项时必填 | 字符串列表,如 `["≤500万", "500–1000万", "暂不确定"]` | +| `allow_multiple` | 可选 | 默认 **不传 / False = 单选**;仅当用户**确实可同时选多项**时传 `True` | + +### 3.2 `clarification_type` 怎么选 + +| 类型 | 何时使用 | 选项 UI | +|------|----------|---------| +| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` | +| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 | +| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) | +| `risk_confirmation` | 需要用户明确确认风险 | **单选** | +| `suggestion` | 你有建议,需用户批准 | **单选** | + +### 3.3 选项卡片(前端行为) + +- 你提供 `options` 后,前端会渲染**可点击卡片**。 +- **默认单选**:用户点一项即发送,无需再点「发送」。 +- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。 +- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。 + +### 3.4 调用示例 + +**缺信息 + 单选预算档:** +``` +ask_clarification( + question="这次任务的预算大致在哪个区间?", + clarification_type="missing_info", + context="预算会影响后续圆桌的可行方案范围", + options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"] +) +``` + +**选部署方式(单选):** +``` +ask_clarification( + question="优先采用哪种部署方式?", + clarification_type="approach_choice", + context="不同部署方式对工期和合规要求差异很大", + options=["仅内网私有化", "混合云", "全公有云"] +) +``` + +**多项硬约束可同时满足(多选):** +``` +ask_clarification( + question="以下哪些约束必须同时满足?(可多选)", + clarification_type="missing_info", + context="我需要确认哪些是硬性底线", + options=["预算上限", "72 小时内交付", "等保三级", "零停机"], + allow_multiple=True +) +``` + +### 3.5 同轮多个独立问题 + +若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。 +若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。 + +--- + +## 4. 信息充分性判定 + +满足 **全部** 条件即可结束澄清: + +1. 能写出一句明确的 `objective`(用户希望实现 X) +2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一) +3. 没有会妨碍 Step 2 研讨的明显歧义 + +若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。 + +--- + +## 5. 结束协议:`[INTENT_READY]` + +信息充分时,回复结构必须是: + +1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review) +2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」) + +格式要求(**严格**): + +``` +[INTENT_READY] +```json +{ + "objective": "用一句话写出用户最终要做的事", + "constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...], + "assumptions": ["假设 1", "假设 2"] +} +``` +``` + +- `[INTENT_READY]` **单独成行**,前后不要夹其他字符 +- 标记后**紧跟** ```json 代码块 +- 字段名必须是 `objective` / `constraints` / `assumptions` +- `constraints` 至少 1 条;`assumptions` 可为 `[]` + +**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。** + +--- + +## 6. 输出风格 + +- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短) +- 提问要**具体**(❓),汇总要**结构化**(✅ 📋) +- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总 + +--- + +## 7. 禁止事项 + +- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用 +- ❌ 输出具体实施方案、架构图、代码 +- ❌ 在已输出 `[INTENT_READY]` 之后继续提问 +- ❌ 用「还有什么要补充的吗」代替 `ask_clarification` + +--- + +## 8. 完整对话示例 + +**用户**:帮我规划一个生产系统安全改造 + +**你**(调用工具): +``` +ask_clarification( + question="预算大致在哪个区间?工期有没有硬性上限?", + clarification_type="missing_info", + context="这两项是后续圆桌排期的基础", + options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"] +) +``` + +**用户**:我选择:预算 300–800万 / 6–12个月 + +**你**(若还需确认合规): +``` +ask_clarification( + question="是否需要满足特定合规标准?", + clarification_type="missing_info", + options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"] +) +``` + +**用户**:我选择:等保 2.0 + +**你**(汇总,不再调用工具): +✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。 + +📋 + +[INTENT_READY] +```json +{ + "objective": "对生产系统进行安全改造,满足等保 2.0 合规要求", + "constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"], + "assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"] +} +``` +""" + + +RECOMMENDER_AGENT_ID = "roundtable-recommender" +RECOMMENDER_AGENT_NAME = "智能体推荐" +RECOMMENDER_AGENT_DESCRIPTION = ( + "智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 " + "2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单" +) + +RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员 + +## 角色定位 +你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。 + +## 核心职责 +- 仔细阅读用户的任务目标与约束 +- 浏览候选池中每个智能体的 `name` 与 `description` +- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个) +- 为每个推荐的智能体写一句**具体、可验证**的推荐理由——必须能回答"它能为这次任务做什么" +- 不推荐重复职能的智能体(如果两个智能体能力高度重叠,只选一个) +- 不推荐与任务明显无关的智能体(宁缺毋滥) + +## 数量约束 +- **下限**:至少推荐 2 个(单个智能体谈不上"圆桌") +- **上限**:最多推荐 6 个(超过 6 个会让协调智能体派活混乱、上下文膨胀) +- 候选池不足 2 个时,有几个推几个 + +## 输出协议 +**你必须严格按下述格式输出**(后端用正则识别): + +1. 先用自然语言简短说明你的推荐思路(1-3 句话,让用户能 review) +2. 然后输出标记 `[RECOMMEND_READY]` 单独成行 +3. 紧接一个 ```json 代码块,内容为: + +```json +{ + "picks": [ + {"agent_id": "<候选池中的智能体 id>", "reason": "<一句推荐理由>"}, + ... + ] +} +``` + +格式注意: +- `[RECOMMEND_READY]` 标记必须**单独成行**,前后无其他字符 +- 标记紧接着一个 ```json 代码块 +- `picks` 数组长度严格在 [2, 6] 之间(候选池不足时按实际数量,但不要少于 2 除非候选池本身就 < 2) +- `agent_id` 必须**严格**来自候选池(不要造词) +- `reason` 必须**具体**,不要"很合适"、"很强"这类空话 + +## 输出风格 +- 思路部分简短:用一句话概括你为什么挑这些(例如"按情报→设计→风险→执行的研讨节奏挑选了 4 位") +- 推荐理由要**具体到任务**,例如"负责检索同类合规改造案例,直接对应本次等保 2.0 的对标需求" +- 适度用 emoji:📋 表示开始汇总 + +## 边界 +- 不调用任何工具(不联网、不执行命令、不读写文件) +- 不评价候选智能体的优劣(只判断"是否适合本次任务") +- 不发起对话、不反问用户——你只产出推荐结果 +- 候选池里没有的智能体绝不出现在 `picks` 中 + +## 示例 + +**用户输入**(由后端拼装): +``` +任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求 +约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0 +关键假设:现有系统具备基础数字化能力;允许预定窗口停机 + +候选智能体池: +- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报 +- roundtable-environment (环境评估):评估硬件容量与系统量化承载力 +- roundtable-solution-design (方案设计):产出主备方案与关键路径 +- roundtable-risk-review (风险审查):合规红线检查与收敛建议 +- roundtable-execution-plan (执行规划):里程碑与资源排布 +- roundtable-summary (总结陈述):归纳冲突标记与盖印 +- my-cost-calc (成本测算):基于预算和资源单价计算总成本 +``` + +**你的输出**: +按"情报→设计→风险→执行"的研讨节奏,挑选 5 位最贴合本次等保 2.0 改造任务的智能体,并加入成本测算覆盖预算硬约束。📋 + +[RECOMMEND_READY] +```json +{ + "picks": [ + {"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"}, + {"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"}, + {"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"}, + {"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"}, + {"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"} + ] +} +``` +""" + + +_BUILTIN_FUNCTIONAL_AGENTS: tuple[tuple[str, str, str, str], ...] = ( + (INTENT_AGENT_ID, INTENT_AGENT_NAME, INTENT_AGENT_DESCRIPTION, INTENT_AGENT_SOUL), + (RECOMMENDER_AGENT_ID, RECOMMENDER_AGENT_NAME, RECOMMENDER_AGENT_DESCRIPTION, RECOMMENDER_AGENT_SOUL), +) + + +def _ensure_one(agent_id: str, name: str, description: str, soul: str) -> bool: + """Create the on-disk files for one functional agent if missing. + + Returns True if files were just created, False if the directory already + existed (and we didn't touch it — respect any local edits). + """ + agent_dir = get_paths().agent_dir(agent_id) + if agent_dir.exists(): + return False + + agent_dir.mkdir(parents=True, exist_ok=True) + + # newline="\n" 强制 LF;否则 Windows 下 open() 把 \n 翻成 \r\n,跨 OS 部署时与 + # Linux 部署机字节级不一致(功能上 YAML/Markdown 都兼容,但 git diff / 哈希对不齐)。 + config_yaml = yaml.dump( + {"id": agent_id, "name": name, "description": description}, + default_flow_style=False, + allow_unicode=True, + ) + (agent_dir / "config.yaml").write_bytes(config_yaml.encode("utf-8")) + (agent_dir / "SOUL.md").write_bytes(soul.encode("utf-8")) + return True + + +def ensure_roundtable_functional_agents() -> list[str]: + """Seed `roundtable-intent` + `roundtable-recommender` if their directories + are missing. Idempotent — fast no-op when both exist. + + Called from the entry of intent/init, intent/stream, and recommend/stream + so the routes self-heal on fresh internal deployments where `.deer-flow/` + is gitignored and the agent directories were never shipped. + + Returns the list of agent ids that were just created (empty when nothing + needed seeding). Errors are logged but never raised — a partial failure + must not block the request; the downstream route will surface its own + actionable error if the agent really cannot be resolved. + """ + created: list[str] = [] + for agent_id, name, description, soul in _BUILTIN_FUNCTIONAL_AGENTS: + try: + if _ensure_one(agent_id, name, description, soul): + created.append(agent_id) + except Exception: + logger.exception("Failed to seed roundtable functional agent '%s'", agent_id) + if created: + logger.info("Seeded missing roundtable functional agents: %s", ", ".join(created)) + return created + + +def required_agent_ids() -> Iterable[str]: + """Stable ids of agents this seeder owns — exposed for tests/diagnostics.""" + return tuple(a[0] for a in _BUILTIN_FUNCTIONAL_AGENTS) diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 5a83873c..050d560b 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -63,6 +63,7 @@ from fastapi.responses import StreamingResponse # Reuse the building blocks already in multi_agent.py — keeps both routers # identical in connection handling, payload shape, and error mapping. +from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents from app.gateway.routers.multi_agent import ( _auth_headers, _create_thread, @@ -107,6 +108,10 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, 与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位 thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。 """ + # 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在, + # 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。 + ensure_roundtable_functional_agents() + base = _loopback_base(request) headers = _auth_headers(request) # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, @@ -159,6 +164,9 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin if not message: raise HTTPException(status_code=400, detail="message is required") + # 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。 + ensure_roundtable_functional_agents() + base = _loopback_base(request) headers = _auth_headers(request) diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommend.py b/offline-backend-20260512/backend/app/gateway/routers/recommend.py index 45b0cb81..14945c8a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommend.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommend.py @@ -47,6 +47,7 @@ import httpx from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import StreamingResponse +from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents from app.gateway.routers.multi_agent import ( _auth_headers, _create_thread, @@ -189,6 +190,10 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea if not candidate_ids: raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id") + # 调用前自检:内网部署 .deer-flow/ 被 gitignore,roundtable-recommender 目录可能不存在, + # 缺失则用嵌入模板补建,避免下游 lead_agent 解析失败抛 500。 + ensure_roundtable_functional_agents() + prompt = _build_prompt(intent, candidates) base = _loopback_base(request) headers = _auth_headers(request) From daba5c05992505458bf35570aad08cbdbca694d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 26 May 2026 14:18:00 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/workspace-nav-chat-list.tsx | 4 +- .../backend/app/gateway/routers/intent.py | 5 +- .../app/gateway/routers/multi_agent.py | 137 ++++++++++++++---- .../backend/app/gateway/routers/recommend.py | 5 +- offline-backend-20260512/backend/config.yaml | 7 + .../harness/deerflow/config/__init__.py | 2 + .../harness/deerflow/config/app_config.py | 2 + .../harness/deerflow/config/gateway_config.py | 46 ++++++ 8 files changed, 174 insertions(+), 34 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/gateway_config.py diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 24b099af..99f69bf8 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -119,7 +119,7 @@ export function WorkspaceNavChatList({ - {/* + 多智能体规划 - */} + dict[str, base = _loopback_base(request) headers = _auth_headers(request) - # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, - # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + # TCP loopback 到 127.0.0.1:(详见 multi_agent._make_loopback_client)。 + # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端 + # 看不到逐 token 增量。 try: async with _make_loopback_client(request) as client: thread_id = await _create_thread(client, base, headers) diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index 27527643..d7d3e8ae 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -49,12 +49,14 @@ from __future__ import annotations import asyncio import json import logging +import os import re import time import traceback from typing import Any, AsyncIterator import httpx +from deerflow.config import get_app_config from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import StreamingResponse @@ -97,12 +99,15 @@ def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, A if isinstance(root, httpx.ConnectError): target = str(root).strip().lower() - # loopback 已改用 ASGITransport(进程内直调),不再经网络;ConnectError 几乎全部来自上游 LLM。 + # loopback 走真 TCP 到 127.0.0.1:;失败 = Gateway 进程没起 / 端口被占。 + # outbound 失败(其它 host)= LLM 提供商不可达。 if "127.0.0.1" in target or "localhost" in target or "loopback" in target: code = "loopback_connect_failed" hint = ( - "本地 Gateway ASGI loopback 失败:这种情况罕见,通常意味着 ASGI app " - "未正确注入到 request.app,或路由层抛出未捕获异常。请查后端日志栈。" + "本地 Gateway loopback 连接失败:确认后端进程正在监听配置的端口。" + "端口配置优先级(高→低):env DEER_FLOW_GATEWAY_PORT > config.yaml 的 " + "gateway.port > 默认 8001。若部署机改了端口,在 config.yaml 加 " + "`gateway:\\n port: <端口>` 即可。" ) else: code = "upstream_connect_failed" @@ -160,42 +165,116 @@ def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, A # --------------------------------------------------------------------------- # Loopback 辅助函数(Gateway → Gateway,同进程) # -# 通过 httpx ASGITransport 直接调用本进程的 ASGI app,**不走 TCP / 不经过网络栈**: -# - 彻底规避「开发机 HTTP_PROXY 把 loopback 也走代理」导致的 40s 超时 -# - 不再需要 trust_env=False / 真实端口 / 真实 host -# - 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应一切照旧 +# 圆桌三路由(intent / recommend / multi_agent)需要从一个 FastAPI handler 里 +# 调用本机 Gateway 的另一个 endpoint(`/api/threads`、`/api/agents`、 +# `/api/threads/{id}/runs/stream`)。这里用真 TCP loopback 到本机的 127.0.0.1 +# + Gateway 监听端口。 # -# 历史背景:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:`, -# 见 git log 与 multi-agent-backend-dev.md §9.1。改用 ASGITransport 后已不需要 -# 防代理 workaround,但 _loopback_base 仍返回一个占位 base_url 以保持原有 helper -# 函数签名(_create_thread / _stream_upstream 等)零改动。 +# ⚠️ 历史踩坑(三个) ⚠️ +# 1. 直接走系统代理:开发机若设了 HTTP_PROXY / HTTPS_PROXY,httpx 默认会把 +# 127.0.0.1 也走代理 → 上游代理不可达,40s 超时(典型日志 headers_in=42.160s)。 +# → 修法:client 用 `trust_env=False`,强制忽略代理 env vars。 +# +# 2. 用 request.url.netloc 拼 base URL:Nginx 反代部署时,客户端打到 nginx 的 +# public host(如 example.com),nginx 把请求转给 gateway 时 Host header +# 通常是 `gateway:8001` 或 public host;后者 httpx 会真的去 DNS 解析, +# 在 gateway 容器内可能不可达 → 连接失败。 +# → 修法:**写死 127.0.0.1**,完全无视 request.url.netloc。 +# +# 3. 一度尝试用 httpx.ASGITransport(app=request.app) 进程内直调,看似一劳永逸, +# 但 httpx 0.28 的 ASGITransport 对 streaming response **整段 buffer**, +# 一直收齐所有 body chunks 才一次性把响应给消费方 → SSE 完全失效,前端表现 +# 为"调度阶段很长 + 流式结果一次性出现"。已弃用。 +# → 详见:git log 6e3a07b(那次改 ASGI 引入)及随后回退。 +# +# 端口策略(优先级,从高到低): +# 1. 环境变量 DEER_FLOW_GATEWAY_PORT +# ── 部署侧紧急覆盖通道,适合 docker-compose / k8s 注入或临时调试 +# 2. config.yaml 的 `gateway.port` +# ── 部署侧**推荐**的配置方式,改完不需要重启二进制(get_app_config 有 mtime +# 热加载,但仍建议跑一次 reload 以确保所有 worker 拿到新值) +# 3. request.url.port +# ── 浏览器直连 gateway:8001 时拿得到;走 Nginx 80/443 反代时会落到下一档 +# 4. 默认 8001(与 `make dev` / `make gateway` / `scripts/start-all.sh` 写死的一致) +# +# 端口在哪里改(给运维 / 部署侧的提示): +# - yaml 配置:打开 config.yaml,加 / 改: +# gateway: +# port: 9999 +# - 环境变量: +# export DEER_FLOW_GATEWAY_PORT=9999 +# +# 修改这两个函数时务必跑一次 SSE 流式 smoke 验证(见 multi-agent-backend-dev.md +# §13 测试建议),确认 chunks 是逐个到达而不是一次喷出。 # --------------------------------------------------------------------------- -# ASGITransport 不在意 host,仅作 URL 拼接占位。 -_LOOPBACK_BASE_URL = "http://loopback" +# 写死的最终 fallback。**不要**为了改端口而修改这个常量——优先用 config.yaml / +# env 这两层(它们在部署侧不需要改源码即可生效)。这个值只在 yaml 缺失 + env +# 没设 + 请求里也拿不到合法 port 时才会用到。 +_DEFAULT_GATEWAY_PORT = 8001 + + +def _resolve_gateway_port(request: Request) -> int: + """按四档 fallback 解析 Gateway 监听端口。详见模块顶部「端口策略」。""" + # 第 1 档:环境变量。允许非法值(garbage),只警告并继续下一档,不抛异常。 + env_port = os.environ.get("DEER_FLOW_GATEWAY_PORT", "").strip() + if env_port: + try: + port = int(env_port) + if 1 <= port <= 65535: + return port + raise ValueError("out of range") + except ValueError: + logger.warning( + "DEER_FLOW_GATEWAY_PORT=%r 不是合法端口,fallback 到 config.yaml / request / %d", + env_port, + _DEFAULT_GATEWAY_PORT, + ) + + # 第 2 档:config.yaml 的 gateway.port。GatewayConfig.port 的 pydantic 校验 + # 已经保证它在 1-65535 范围内,无需再次校验。读 config 失败时降级,不阻断业务。 + try: + configured = get_app_config().gateway.port + if configured: + return configured + except Exception as exc: + # 极端场景:config.yaml 不存在或解析失败。这种情况下 gateway 本身也起不来, + # loopback 端口"猜错"也已经无所谓。仅记 debug 日志方便排查。 + logger.debug("read gateway.port from app config failed: %s", exc) + + # 第 3 档:从当前请求拿 port。浏览器直连 gateway 时是真实监听端口; + # 走 Nginx 反代时通常是 80/443(public port),那就跳过,落到最终默认。 + inbound_port = request.url.port + if inbound_port is not None and inbound_port not in (80, 443): + return inbound_port + + # 第 4 档:写死默认。 + return _DEFAULT_GATEWAY_PORT def _loopback_base(request: Request) -> str: - """返回 loopback base URL 占位串。 + """返回 `http://127.0.0.1:`,供 loopback 调用拼 URL。 - ASGI transport 模式下不再用真实 scheme+host,但保留此函数以兼容 - `_create_thread / _stream_upstream` 等内部 helper 的 `base` 参数。 + 强制 127.0.0.1 是为了避免 Nginx 反代场景下读到经过 Host 改写的 netloc。 + 端口按 `_resolve_gateway_port` 的四档 fallback 解析。 """ - return _LOOPBACK_BASE_URL + return f"http://127.0.0.1:{_resolve_gateway_port(request)}" def _make_loopback_client(request: Request) -> httpx.AsyncClient: - """构造一个走 ASGITransport 的 httpx.AsyncClient,**不走网络栈**。 + """构造同进程 loopback 用的 httpx.AsyncClient(真 TCP)。 - 所有圆桌路由(intent / recommend / multi_agent)的 loopback 调用都走这里, - 替代原先的 `httpx.AsyncClient(timeout=None, trust_env=False)` + 127.0.0.1。 + 所有圆桌路由(intent / recommend / multi_agent)的 loopback 调用必须走这里, + 保持配置一致。 + - `trust_env=False`:**关键**,忽略 HTTP_PROXY / HTTPS_PROXY,防代理拦截 127.0.0.1 + - `timeout=None`:SSE 长连接需要,LangGraph runs/stream 可能跑数十秒 + - `request` 参数当前未被使用,保留是为了:(a) 未来若需要从 request 取部署 + 上下文(如多租户端口隔离)可以无侵入扩展;(b) 与调用方现有签名兼容。 """ - transport = httpx.ASGITransport(app=request.app) return httpx.AsyncClient( - transport=transport, - base_url=_LOOPBACK_BASE_URL, timeout=None, + trust_env=False, ) @@ -462,8 +541,8 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" async def stream_generator() -> AsyncIterator[str]: - # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, - # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + # TCP loopback 到 127.0.0.1:。client 配置见 _make_loopback_client: + # trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。 async with _make_loopback_client(request) as client: try: # Per-seat prepare task — same body as before, but we no longer @@ -581,7 +660,9 @@ async def _stream_upstream( - ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表, 供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。 - 日志 ``[upstream-timing]``:排查首 token 慢;loopback 已走 ASGITransport,不会再有代理超时。 + 日志 ``[upstream-timing]``:排查首 token 慢;headers_in 长尾若 >> 网络往返(loopback + 本机往返通常 <10ms),怀疑 HTTP_PROXY 拦了回环——检查 client 是否走 _make_loopback_client + (trust_env=False)。 """ url = f"{base}/api/threads/{thread_id}/runs/stream" @@ -997,8 +1078,8 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, - # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + # TCP loopback 到 127.0.0.1:。client 配置见 _make_loopback_client: + # trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。 async with _make_loopback_client(request) as client: try: if agent_type == "leader": diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommend.py b/offline-backend-20260512/backend/app/gateway/routers/recommend.py index 14945c8a..8ff444d7 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommend.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommend.py @@ -199,8 +199,9 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, - # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + # TCP loopback 到 127.0.0.1:(详见 multi_agent._make_loopback_client)。 + # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端 + # 看不到逐 token 增量。 async with _make_loopback_client(request) as client: try: # 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。 diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 3208a97c..219b4b9e 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -2,6 +2,13 @@ config_version: 8 log_level: info +# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程 +# loopback 调用会拼 http://127.0.0.1: 打到本机其它 endpoint,所以必须与 +# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。 +# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。 +gateway: + port: 8001 + models: - name: deepseek-chat display_name: DeepSeek Chat diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/__init__.py index 42a91757..7d9cb6e1 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/__init__.py @@ -1,5 +1,6 @@ from .app_config import get_app_config from .extensions_config import ExtensionsConfig, get_extensions_config +from .gateway_config import GatewayConfig from .memory_config import MemoryConfig, get_memory_config from .paths import Paths, get_paths from .skill_evolution_config import SkillEvolutionConfig @@ -20,6 +21,7 @@ __all__ = [ "SkillsConfig", "ExtensionsConfig", "get_extensions_config", + "GatewayConfig", "MemoryConfig", "get_memory_config", "get_tracing_config", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index 2d56d443..a8d9b58b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -13,6 +13,7 @@ from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_c from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict from deerflow.config.database_config import DatabaseConfig from deerflow.config.extensions_config import ExtensionsConfig +from deerflow.config.gateway_config import GatewayConfig from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.model_config import ModelConfig @@ -122,6 +123,7 @@ class AppConfig(BaseModel): skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration") model_config = ConfigDict(extra="allow") database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration") + gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)") run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/gateway_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/gateway_config.py new file mode 100644 index 00000000..008324b1 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/gateway_config.py @@ -0,0 +1,46 @@ +"""Gateway 进程的本机绑定配置。 + +目前唯一字段是 ``port``——Gateway FastAPI 的监听端口。圆桌三路由 +(intent / recommend / multi_agent) 内部用 loopback 调本机的其它 endpoint +(``/api/threads``、``/api/agents`` 等),需要知道这个端口才能拼出 base URL。 + +为什么放在配置里而不是写死 +-------------------------- +- 部署方常常会改 Gateway 监听端口(端口被占用、合规要求、容器化端口映射), + 写死代码意味着每次改端口都得改源码 + 重新构建/分发,**改 config 不需要重启二进制** +- ``DEER_FLOW_GATEWAY_PORT`` 环境变量仍然是更高优先级的紧急覆盖通道,适合 + 临时调试或者 docker-compose / k8s 注入 + +修改方式(部署侧) +------------------ +在 ``config.yaml`` 顶层加: + +.. code-block:: yaml + + gateway: + port: 9999 # 默认 8001;部署机改了 gateway 端口时改这里 + +只想临时改一次(不动 yaml)→ 设环境变量: + +.. code-block:: bash + + export DEER_FLOW_GATEWAY_PORT=9999 +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class GatewayConfig(BaseModel): + """Gateway 本机 loopback 端口配置。""" + + port: int = Field( + default=8001, + ge=1, + le=65535, + description=( + "Gateway FastAPI 监听端口。圆桌内部 loopback 调用会拼到 " + "``http://127.0.0.1:``。默认 8001(与 Makefile/scripts/start-all.sh 对齐)。" + ), + )