diff --git a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx index 3ef0b7e1..4a92a95b 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx @@ -77,6 +77,17 @@ export function TimelineStep({ event, isLast, children }: TimelineStepProps) { {event.detail}

)} + {/* 接口解析失败时的原始错误响应体,默认折叠,点击展开 */} + {event.raw && ( +
+ + 错误返回结果(点击展开) + +
+                  {event.raw}
+                
+
+ )} ) : ( {event.message} diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 17d546b1..2e936da3 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -90,6 +90,8 @@ export interface ProgressEvent { // notice(非致命告警)/ error:标题 + 真实原因 title?: string; detail?: string; + // 接口解析失败时的原始错误响应体,前端默认折叠展示 + raw?: string; // materials_ready materials?: AIMaterial[]; keywords?: string[]; diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py index 688b9a02..d037e6a3 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py @@ -21,9 +21,15 @@ logger = logging.getLogger(__name__) _MAX_MATERIALS = 12 -def _notice(title: str, detail: str) -> dict: - """构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。""" - return {"type": "notice", "agent_name": "素材收集专家", "title": title, "detail": detail} +def _notice(title: str, detail: str, raw: str = "") -> dict: + """构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。 + + raw 非空时附带接口的原始错误响应体,前端默认折叠展示。 + """ + event = {"type": "notice", "agent_name": "素材收集专家", "title": title, "detail": detail} + if raw: + event["raw"] = raw + return event async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict: @@ -173,7 +179,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}")) if search_errors: uniq = list(dict.fromkeys(search_errors)) - notices.append(_notice("ES 波动导致检索异常", ";".join(uniq[:3]))) + # 解析失败时收集到的接口原始响应体,前端折叠展示便于定位 + raw_dumps = list(dict.fromkeys(getattr(provider, "raw_responses", []) or [])) + notices.append(_notice( + "ES 波动导致检索异常", + ";".join(uniq[:3]), + raw="\n\n----------\n\n".join(raw_dumps[:3]), + )) return { "material_package": pkg, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py index 216a96cc..7a8bf1e3 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py @@ -11,6 +11,8 @@ logger = logging.getLogger(__name__) # 内网知识库默认检索接口与知识库名(可经 config.yaml 的 ai_writing 段覆盖) _DEFAULT_ENDPOINT = "http://10.106.57.180:7863/knowledge_base/search_docs_es" _DEFAULT_KB = "samples" +# 单条原始响应体最大保留长度,避免把超大响应整段灌进前端时间线 +_RAW_SNIPPET_LIMIT = 2000 class IntranetSearchProvider(BaseSearchProvider): @@ -25,14 +27,26 @@ class IntranetSearchProvider(BaseSearchProvider): self, base_url: str = "", knowledge_base_name: str = _DEFAULT_KB, - timeout: int = 15, + timeout: int = 120, **_ignored, ): self.endpoint = base_url or _DEFAULT_ENDPOINT self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB + # 内网 ES 接口可能较慢,超时放宽到 2 分钟 self.timeout = timeout # 收集本次运行的检索异常,供 researcher 节点取出并提示用户(ES 波动等) self.errors: list[str] = [] + # 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示 + self.raw_responses: list[str] = [] + + def _record_raw(self, body: str) -> None: + """收集解析失败 / 结构异常时的原始响应体,供前端折叠查看。""" + body = (body or "").strip() + if not body: + return + if len(body) > _RAW_SNIPPET_LIMIT: + body = body[:_RAW_SNIPPET_LIMIT] + "…(响应体过长,已截断)" + self.raw_responses.append(body) async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]: import httpx @@ -42,21 +56,38 @@ class IntranetSearchProvider(BaseSearchProvider): "knowledge_base_name": self.knowledge_base_name, "ts": "yes", } + # 先拿到原始响应文本,JSON 解析失败时把它原样回传前端便于定位接口返回了什么 + raw_text = "" try: async with httpx.AsyncClient(timeout=self.timeout) as client: resp = await client.post(self.endpoint, json=payload) resp.raise_for_status() + raw_text = resp.text data = resp.json() - except Exception as e: + except httpx.HTTPStatusError as e: + # 非 2xx:接口有响应但状态码异常,把响应体一并暴露给前端 logger.warning("intranet search failed for query=%r: %s", query, e) self.errors.append(f"{type(e).__name__}: {e}") + self._record_raw(e.response.text if e.response is not None else "") + return [] + except Exception as e: + # 连接失败 / 超时 / JSON 解析失败等;JSON 解析失败时 raw_text 已是接口响应体 + logger.warning("intranet search failed for query=%r: %s", query, e) + self.errors.append(f"{type(e).__name__}: {e}") + self._record_raw(raw_text) return [] - # 接口直接返回数组;兼容个别情况下包了一层 data/results 的情况 - if isinstance(data, dict): + # 正常情况下接口直接返回数组;兼容个别情况下包了一层 data/results 的结构 + if isinstance(data, list): + items = data + elif isinstance(data, dict): items = data.get("data") or data.get("results") or [] else: - items = data or [] + # 返回结构既不是数组也不是已知 dict 包装:视为异常,把原始响应回传前端 + logger.warning("intranet search unexpected response type for query=%r: %s", query, type(data).__name__) + self.errors.append(f"返回结构异常:期望数组,实际为 {type(data).__name__}") + self._record_raw(raw_text) + return [] results: List[SearchResult] = [] for i, item in enumerate(items[:max_results]):