From da3d38013e3062f023580222a320bf6859ef736f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 16 Jun 2026 14:15:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-writing):=20=E7=B4=A0=E6=9D=90=E6=94=B6?= =?UTF-8?q?=E9=9B=86=E6=8A=80=E8=83=BD=E8=B0=83=E7=94=A8=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E5=AE=B9=E9=94=99=E5=9B=9E=E9=80=80=E5=88=B0=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认/选中技能调用失败(超时、报错、全部检索词失败)时不再直接报错, 改用其他技能继续检索:节点 emit skill_error 步骤(带真实原因),配置技能 全部失败且无新素材时跑 fallback 兜底(内置知识库检索优先,再到目录其它已 启用技能)。前端 SkillStepsCard 渲染 skill_error(玫红)/ fallback(琥珀), 时间线展示"技能报错→改用其他技能搜集"。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ai-writing/SkillStepsCard.tsx | 33 +++-- .../src/open-canvas/types/ai-writing.ts | 4 + offline-backend-20260512/backend/CLAUDE.md | 2 +- .../agents/ai_writing/nodes/researcher.py | 114 +++++++++++++++--- 4 files changed, 126 insertions(+), 27 deletions(-) diff --git a/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx index cb8c349c..0faf8d74 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react'; +import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search, AlertTriangle, Shuffle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { SkillSearchStep } from '../../types/ai-writing'; import { CollapseTransition } from './CollapseTransition'; @@ -16,6 +16,8 @@ const STEP_ICON: Record = { select_skills: Sparkles, invoke_skill: Zap, skill_result: CheckCircle2, + skill_error: AlertTriangle, + fallback: Shuffle, stop_early: CircleStop, }; @@ -25,6 +27,8 @@ const STEP_LABEL: Record = { select_skills: '筛选相关技能', invoke_skill: '调用技能', skill_result: '检索结果', + skill_error: '技能调用失败', + fallback: '改用其他技能', stop_early: '提前停止', }; @@ -71,6 +75,9 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) { const label = STEP_LABEL[step.type] ?? step.type; const isStopEarly = step.type === 'stop_early'; const isResult = step.type === 'skill_result'; + // 容错:技能调用失败标红,回退其它技能标琥珀,提示用户"出错了但已自动改用其他技能" + const isError = step.type === 'skill_error'; + const isFallback = step.type === 'fallback'; const isActive = idx === activeIdx; return (
  • @@ -79,11 +86,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) { 'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium', isActive ? 'bg-violet-500 text-white' - : isStopEarly - ? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300' - : isResult - ? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300' - : 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300', + : isError + ? 'bg-rose-100 dark:bg-rose-900/60 text-rose-600 dark:text-rose-300' + : isStopEarly || isFallback + ? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300' + : isResult + ? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300' + : 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300', )} > {isActive ? : idx + 1} @@ -92,11 +101,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
    diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index bb6b783e..361cb090 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -20,6 +20,8 @@ export interface AIMaterial { // select_skills:LLM 按相关度排序的候选 // invoke_skill:开始调用某个技能去检索 // skill_result:某个技能检索返回了 N 条素材 +// skill_error:某个技能调用失败(超时/报错),将改用其他技能(容错) +// fallback:配置技能全部失败,回退到其他技能继续搜集(容错) // stop_early:累计素材达到上限,停止后续技能 export interface SkillSearchStep { type: @@ -28,6 +30,8 @@ export interface SkillSearchStep { | "select_skills" | "invoke_skill" | "skill_result" + | "skill_error" + | "fallback" | "stop_early"; message: string; skillNames?: string[]; diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index f6812132..0e623e64 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -265,7 +265,7 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets//`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`. -**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). Tests: `tests/test_ai_writing_material_source.py`. +**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`. ### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`) 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 c4ceb06f..8a4c58f0 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 @@ -141,6 +141,30 @@ def _load_configured_skills() -> list: return resolved or [_fallback_knowledge_skill()] +def _fallback_candidate_skills(tried: set[str]) -> list: + """容错兜底:从技能目录里挑出**尚未尝试过**的可用技能,内置「知识库检索」优先。 + + 当配置技能调用全部失败、且没收集到任何新素材时,researcher 用这些技能再试一轮, + 而不是直接报错。内置知识库检索(原生多关键词并发,最稳)排在最前,其后是技能目录里 + 其它已启用的技能。技能目录加载失败时只回退内置知识库检索,保证兜底链永不断。 + """ + candidates: list = [] + seen: set[str] = set(tried) + if KNOWLEDGE_SEARCH_SKILL_NAME not in seen: + candidates.append(_fallback_knowledge_skill()) + seen.add(KNOWLEDGE_SEARCH_SKILL_NAME) + try: + from deerflow.skills.storage import get_or_new_skill_storage + + for s in get_or_new_skill_storage().load_skills(enabled_only=True): + if getattr(s, "name", None) and s.name not in seen: + candidates.append(s) + seen.add(s.name) + except Exception: + logger.warning("researcher: 兜底加载技能目录失败", exc_info=True) + return candidates + + def _resolve_keyword_count(raw: object) -> int: """把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。""" try: @@ -511,24 +535,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict added += 1 return added - async def _timed_search(skill_name: str, query: str, limit: int) -> list[SearchResult]: + async def _timed_search(skill_name: str, query: str, limit: int) -> tuple[list[SearchResult], str]: # 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒, # 超时不再阻塞后续技能,避免整段过程看起来"冻结"。 + # 返回 (结果, 错误详情):错误详情非空 = 该技能调用失败(触发兜底)。 try: - return await asyncio.wait_for( + results = await asyncio.wait_for( provider.search(query, max_results=limit), timeout=_SKILL_SEARCH_TIMEOUT_SECONDS, - ) or [] + ) + return (results or []), "" except asyncio.TimeoutError: + msg = f"检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)" logger.warning("skill[%s] search timed out after %ss", skill_name, _SKILL_SEARCH_TIMEOUT_SECONDS) - search_errors.append(f"skill[{skill_name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)") - return [] + search_errors.append(f"skill[{skill_name}]: {msg}") + return [], msg except Exception as e: logger.warning("skill[%s] search failed: %s", skill_name, e) search_errors.append(f"skill[{skill_name}]: {e}") - return [] + return [], str(e) - for sk in ordered: + def _skill_display(sk) -> str: + return _KNOWLEDGE_SKILL_DISPLAY if sk.name == KNOWLEDGE_SEARCH_SKILL_NAME else sk.name + + async def _execute_skill(sk) -> tuple[int, str]: + """运行单个技能检索,返回 (新增素材数, 错误详情)。 + + 错误详情非空 = 该技能整体调用失败(超时 / 抛异常 / 全部检索词失败), + 调用方据此 emit ``skill_error`` 步骤并触发"改用其它技能"的兜底逻辑。 + """ is_knowledge = sk.name == KNOWLEDGE_SEARCH_SKILL_NAME if is_knowledge and not is_user_query_search: # 原生知识库检索:对全部检索词并发检索(旧版默认路径)。 @@ -557,13 +592,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict gathered = await asyncio.gather(*[_search_one(kw) for kw in search_keywords], return_exceptions=True) added = 0 + errs: list[str] = [] for results in gathered: if isinstance(results, Exception): logger.warning("search failed: %s", results) - search_errors.append(f"{type(results).__name__}: {results}") + errs.append(f"{type(results).__name__}: {results}") continue added += _collect(results) search_errors.extend(getattr(provider, "errors", [])) + search_errors.extend(errs) + # 全部检索词都抛异常且 0 新增 → 视为该技能整体调用失败,触发兜底 + error_detail = "" + if added == 0 and errs and len(errs) == total: + error_detail = ";".join(dict.fromkeys(errs))[:200] + return added, error_detail else: # 定向技能检索:user_query 模式优先用用户原话;非知识库技能再 # 附上 description 让 query 贴合该技能的领域。 @@ -576,14 +618,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict "message": f"使用技能「{sk.name}」检索:{skill_query[:60]}", "skill_name": sk.name, }) - added = _collect(await _timed_search(sk.name, skill_query, per_skill)) + results, error_detail = await _timed_search(sk.name, skill_query, per_skill) + return _collect(results), error_detail - _emit_skill_step({ - "type": "skill_result", - "message": f"技能「{_KNOWLEDGE_SKILL_DISPLAY if is_knowledge else sk.name}」检索到 {added} 条新素材", - "skill_name": sk.name, - "matched_count": added, - }) + # 失败技能名(去重后用于兜底提示):记录配置技能里调用失败的那些 + failed_skill_names: list[str] = [] + tried_names: set[str] = set() + + def _emit_skill_outcome(sk, added: int, error_detail: str) -> None: + """单个技能跑完后,按成功 / 失败 emit 对应步骤事件。""" + if error_detail: + failed_skill_names.append(_skill_display(sk)) + _emit_skill_step({ + "type": "skill_error", + "message": f"技能「{_skill_display(sk)}」调用失败,尝试使用其他技能检索", + "skill_name": sk.name, + "reason": error_detail, + }) + else: + _emit_skill_step({ + "type": "skill_result", + "message": f"技能「{_skill_display(sk)}」检索到 {added} 条新素材", + "skill_name": sk.name, + "matched_count": added, + }) + + for sk in ordered: + added, error_detail = await _execute_skill(sk) + tried_names.add(sk.name) + _emit_skill_outcome(sk, added, error_detail) # 早停:累积素材已达上限 if len(new_results) >= max_materials: @@ -594,6 +657,27 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict }) break + # ── 容错兜底:配置技能调用失败且没收集到任何新素材 → 改用其它可用技能继续检索 ── + # (不直接报错;前端时间线靠 skill_error / fallback 步骤展示"技能报错,改用其它技能搜集") + if not new_results and failed_skill_names: + fallback_skills = _fallback_candidate_skills(tried_names) + if fallback_skills: + _emit_skill_step({ + "type": "fallback", + "message": ( + f"已配置技能({'、'.join(dict.fromkeys(failed_skill_names))})检索失败," + "改用其他技能继续搜集素材" + ), + "skill_names": [_skill_display(s) for s in fallback_skills], + }) + for sk in fallback_skills: + added, error_detail = await _execute_skill(sk) + tried_names.add(sk.name) + _emit_skill_outcome(sk, added, error_detail) + if new_results: + # 兜底已拿到素材即止,不再无谓尝试后续技能 + break + # 合并去重 # - 首次检索 / 普通 re_search(无输入,用户只想重出):旧素材丢弃,覆盖式替换 # - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果