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]):