)}
@@ -751,7 +768,10 @@ function RecommenderOutput({ state }: { state: InternalState }) {
{hasReasoning && (
@@ -762,7 +782,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{hasRationale && (
-
+
{cleanedRationale}
{isStreaming && }
diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml
index 5bda2872..86bee47b 100644
--- a/offline-backend-20260512/backend/config.yaml
+++ b/offline-backend-20260512/backend/config.yaml
@@ -23,6 +23,19 @@ models:
supports_vision: true
supports_thinking: true
+ - name: deepseek-chat
+ display_name: DeepSeek Chat
+ use: langchain_openai:ChatOpenAI
+ model: deepseek-chat
+ api_key: sk-1520028668cb4cc388ed8315ff1e856e
+ base_url: https://api.deepseek.com
+ request_timeout: 600.0
+ max_retries: 2
+ max_tokens: 4096
+ temperature: 1.0
+ supports_vision: true
+ supports_thinking: true
+
- name: deepseek-v4-flash
display_name: DeepSeek V4 Flash
use: langchain_openai:ChatOpenAI
@@ -86,6 +99,11 @@ tools:
use: deerflow.community.ddg_search.tools:web_search_tool
max_results: 5
+ - name: web_fetch
+ group: web
+ use: deerflow.community.jina_ai.tools:web_fetch_tool
+ timeout: 15
+
- name: ls
group: file:read
use: deerflow.sandbox.tools:ls_tool
@@ -121,7 +139,7 @@ sandbox:
allow_host_bash: true
skills:
- path: D:\cursor-work\offline-backend-20260512\backend\skills
+ path: skills
security_scan:
enabled: false
diff --git a/offline-backend-20260512/backend/extensions_config.json b/offline-backend-20260512/backend/extensions_config.json
new file mode 100644
index 00000000..b21e0a45
--- /dev/null
+++ b/offline-backend-20260512/backend/extensions_config.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {},
+ "skills": {
+ "web-research": { "enabled": true },
+ "deep-search": { "enabled": true },
+ "code-retrieval": { "enabled": true },
+ "news-briefing": { "enabled": true },
+ "workspace-search": { "enabled": true },
+ "policy-knowledge": { "enabled": true }
+ }
+}
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py
index 6cb08c14..b43035d1 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py
@@ -55,6 +55,9 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
action = user_input.get("action", "confirm")
+ # 单输入框文本:可能是技能名 / 来源 / 检索意图,re_search 和 enable_skill_search 都用得到
+ user_query = (user_input.get("userQuery") or user_input.get("user_query") or "").strip()
+
if action == "confirm":
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
filtered = [m for m in pkg["materials"] if m["id"] in approved_ids]
@@ -66,12 +69,22 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认素材,开始规划大纲"}],
}
- # re_search:重新搜索
+ # enable_skill_search:用户选择"启用技能检索",回到 researcher 走 skill 模式
+ if action == "enable_skill_search":
+ msg = f"已启用技能检索(输入:{user_query[:30]})" if user_query else "已启用技能检索,正在筛选可用技能…"
+ return {
+ "last_user_intervention": {**user_input, "action": "enable_skill_search", "user_query": user_query},
+ "status": "researching",
+ "progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
+ }
+
+ # re_search:重新搜索(支持新版 user_query 单输入框 + 旧版 extraKeywords 数组)
extra_kws = user_input.get("extraKeywords") or []
+ msg = f"按输入「{user_query[:30]}」重新搜索…" if user_query else "重新搜索中..."
return {
- "last_user_intervention": {**user_input, "_extra_keywords": extra_kws},
+ "last_user_intervention": {**user_input, "_extra_keywords": extra_kws, "user_query": user_query},
"status": "researching",
- "progress_events": [{"type": "resumed", "agent_name": "系统", "message": "重新搜索中..."}],
+ "progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
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 25488e18..a381755c 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
@@ -9,7 +9,7 @@ from typing import List
from langchain_core.runnables import RunnableConfig
-from ..search import get_search_provider, SearchResult
+from ..search import get_search_provider, SearchResult, SkillSearchProvider
from ..state import AIWritingState, Material, MaterialPackage
from ..prompts.researcher_prompts import (
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
@@ -96,15 +96,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 检查干预模式(PAUSE-1 素材确认 / 素材不足求助)
intervention = state.get("last_user_intervention") or {}
extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or []
+ # PAUSE-1 单输入框:用户写的自然语言(技能名 / 来源 / 检索意图),优先级最高
+ user_query: str = (intervention.get("user_query") or "").strip()
action = intervention.get("action")
# 素材不足求助点触发的「补充检索」:保留原大纲,检索完直接回写作、不走素材确认/大纲
is_supplement = action == "supplement_search"
- # PAUSE-1 素材确认卡用户点「追加检索」并填了新关键词:
+ # PAUSE-1 素材确认卡用户点「按输入重新搜索」并填了 user_query:
# - 跳过 LLM 重新生成检索词(节省一次调用 + 尊重用户意图)
- # - 只检索 extra_keywords 这几个新词,不重检索之前 LLM 出过的词
+ # - 直接用 user_query 作为唯一检索词
# - 保留 existing material_package,按 URL 去重合并
- is_append_only = action == "re_search" and bool(extra_keywords)
+ is_user_query_search = action == "re_search" and bool(user_query)
+ # 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
+ is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
+ # PAUSE-1 素材确认卡用户点「启用技能检索」:
+ # - 不再走 web/intranet 检索,改用 SkillSearchProvider
+ # - 顺序调用候选技能 + 早停(第一个技能够就停,不够再加下一个)
+ # - 旧 material_package 完全丢弃(覆盖式替换,让用户对比 web vs skill 结果)
+ is_skill_search = action == "enable_skill_search"
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
@@ -126,10 +135,14 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
writer({"type": "research_progress", "message": message})
# Step 1: 提取关键词
- kw_result: dict = {} # 走追加分支时跳过 LLM 调用,但下方 notice 拼接需要兜底值
- if is_append_only:
- # 追加模式:直接用用户填的关键词,不再调 LLM
- keywords: list[str] = _clean_keywords(extra_keywords, intent)
+ kw_result: dict = {} # 走"用户输入 / 追加关键词"分支时跳过 LLM 调用,notice 拼接需要兜底值
+ if is_user_query_search:
+ # 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分
+ keywords: list[str] = [user_query]
+ _emit(f"按你的输入「{user_query[:30]}」检索…")
+ elif is_append_only:
+ # 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
+ keywords = _clean_keywords(extra_keywords, intent)
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
@@ -150,13 +163,31 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
- # Step 2: 并发搜索
- provider = get_search_provider(ai_writing_config)
+ # Step 2: 搜索
+ # 技能检索:用 SkillSearchProvider 替代 web/intranet;步骤事件单独走 skill_search_step 通道
+ skill_steps: list[dict] = []
+
+ if is_skill_search:
+ def _emit_skill_step(step: dict) -> None:
+ skill_steps.append(step)
+ writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
+
+ async def _llm_select(system: str, user_prompt: str, fallback: dict) -> dict:
+ return await llm_json(system, user_prompt, fallback=fallback, config=config, model_name=keyword_model)
+
+ provider = SkillSearchProvider(
+ llm_select=_llm_select,
+ emit_step=_emit_skill_step,
+ max_select=max(1, min(int(max_materials), 10)),
+ user_query=user_query,
+ )
+ else:
+ provider = get_search_provider(ai_writing_config)
# 按用户指定的关键词数量截断。
- # 追加模式:keywords 本身就是用户填的,全部检索不截断(用户填 N 个就搜 N 个)。
+ # 用户输入 / 追加模式:keywords 本身就是用户填的,全部检索不截断。
# 其他模式:受 keyword_count 限制,但 extra_keywords 在内时上限放宽到 keywords 总数。
- if is_append_only:
+ if is_user_query_search or is_append_only:
search_keywords = keywords
else:
search_limit = keyword_count
@@ -168,31 +199,48 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
- # 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁)
- completed = 0
+ # 技能模式:串行调用 provider(早停语义靠 provider 内部累积计数)
+ # 其它模式:并发调用,每完成一个就推送进度
+ if is_skill_search:
+ all_results_nested: list[list[SearchResult]] = []
+ for kw in search_keywords:
+ try:
+ res = await provider.search(kw, max_results=per_keyword, user_intent=intent)
+ except Exception as e:
+ logger.warning("skill search failed: %s", e)
+ all_results_nested.append(e) # type: ignore[arg-type]
+ continue
+ all_results_nested.append(res)
+ # provider 内部 early_stop_at 已触发时不会再返回新 skill,提前 break 避免空轮询
+ if not res:
+ break
+ else:
+ completed = 0
- async def _search_one(kw: str) -> list[SearchResult]:
- nonlocal completed
- try:
- return await provider.search(kw, max_results=per_keyword)
- finally:
- completed += 1
- if completed < total:
- _emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
- else:
- _emit(f"已完成全部 {total} 次检索")
+ async def _search_one(kw: str) -> list[SearchResult]:
+ nonlocal completed
+ try:
+ return await provider.search(kw, max_results=per_keyword)
+ finally:
+ completed += 1
+ if completed < total:
+ _emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
+ else:
+ _emit(f"已完成全部 {total} 次检索")
- search_tasks = [_search_one(kw) for kw in search_keywords]
- all_results_nested: list[list[SearchResult]] = await asyncio.gather(*search_tasks, return_exceptions=True)
+ search_tasks = [_search_one(kw) for kw in search_keywords]
+ all_results_nested = await asyncio.gather(*search_tasks, return_exceptions=True)
# 合并去重
# - 首次检索 / 普通 re_search(不带 extra_keywords,用户只想 LLM 重出):旧素材丢弃
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
# - PAUSE-1 追加检索 is_append_only:保留旧素材 + 仅检索新关键词
+ # - 技能检索 is_skill_search:覆盖式替换,旧素材完全丢弃(用户期望对比 web vs skill 结果)
seen_urls: set[str] = set()
+ seen_skill_ids: set[str] = set() # skill 素材无 URL,按 skill_name 去重
raw_materials: list[Material] = []
existing_pkg = state.get("material_package")
- preserve_existing = existing_pkg and (is_supplement or is_append_only)
+ preserve_existing = existing_pkg and (is_supplement or is_append_only) and not is_skill_search
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
@@ -205,10 +253,19 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
search_errors.append(f"{type(results).__name__}: {results}")
continue
for r in results:
- if r.url and r.url in seen_urls:
+ # skill 素材按 skill_name 去重(同一个 skill 被多个关键词命中只保留一次)
+ if r.source_type == "skill":
+ meta = getattr(r, "extra_metadata", {}) or {}
+ sk_name = meta.get("skill_name", "")
+ if sk_name and sk_name in seen_skill_ids:
+ continue
+ if sk_name:
+ seen_skill_ids.add(sk_name)
+ elif r.url and r.url in seen_urls:
continue
- seen_urls.add(r.url)
- raw_materials.append(Material(
+ else:
+ seen_urls.add(r.url)
+ mat: Material = Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
@@ -218,7 +275,11 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
- ))
+ )
+ extra = getattr(r, "extra_metadata", None)
+ if extra:
+ mat["extra_metadata"] = extra
+ raw_materials.append(mat)
# provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户
search_errors.extend(getattr(provider, "errors", []))
@@ -264,6 +325,14 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
old_kws = list(existing_pkg.get("keywords") or [])
pkg_keywords = list(dict.fromkeys(old_kws + keywords))
pkg = MaterialPackage(keywords=pkg_keywords, materials=materials, summary=summary)
+ # 技能检索时把过程步骤一并放进 pkg,前端在素材确认卡里能展示"使用了哪些技能"
+ if is_skill_search:
+ pkg["skill_search_used"] = True
+ pkg["skill_search_steps"] = skill_steps
+ elif existing_pkg and existing_pkg.get("skill_search_used") and (is_supplement or is_append_only):
+ # 保留之前一次技能检索的痕迹(不污染纯 web 检索)
+ pkg["skill_search_used"] = existing_pkg.get("skill_search_used", False)
+ pkg["skill_search_steps"] = existing_pkg.get("skill_search_steps", [])
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
notices: list[dict] = []
@@ -281,12 +350,15 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
raw="\n\n----------\n\n".join(raw_dumps[:3]),
))
- # 进度事件文案:追加模式下让用户看到只搜了哪几个新词
- step_start_msg = (
- f"追加检索关键词:{', '.join(keywords[:3])}"
- if is_append_only
- else f"搜索关键词:{', '.join(keywords[:3])}"
- )
+ # 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
+ if is_skill_search:
+ step_start_msg = f"启用技能检索({len(skill_steps)} 步)"
+ elif is_user_query_search:
+ step_start_msg = f"按你的输入检索:{user_query[:40]}"
+ elif is_append_only:
+ step_start_msg = f"追加检索关键词:{', '.join(keywords[:3])}"
+ else:
+ step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
return {
"material_package": pkg,
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py
index 61402670..89e0b408 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/__init__.py
@@ -3,6 +3,7 @@ from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider
from .demo_search import DemoSearchProvider
from .duckduckgo_search import DuckDuckGoSearchProvider
+from .skill_search import SkillSearchProvider
def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
@@ -60,5 +61,6 @@ __all__ = [
"BaseSearchProvider", "SearchResult",
"InternetSearchProvider", "IntranetSearchProvider",
"DemoSearchProvider", "DuckDuckGoSearchProvider",
+ "SkillSearchProvider",
"get_search_provider",
]
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py
new file mode 100644
index 00000000..2a08a773
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py
@@ -0,0 +1,248 @@
+"""技能检索 Provider:把已启用的 Skill 当作可检索的素材源。
+
+流程(保持简单,对应素材确认卡上的"技能检索"按钮):
+1) list_skills 事件 —— 列出当前启用的全部 skill
+2) select_skills 事件 —— LLM 根据用户输入 + 写作意图按相关度选出若干个
+3) invoke_skill 事件 —— 按相关度从高到低顺序产出 SearchResult
+
+输出格式严格对齐 BaseSearchProvider 的 SearchResult,让 researcher 节点
+不需要分支处理,下游 Material 字段同构。
+"""
+from __future__ import annotations
+
+import logging
+import re
+from typing import Callable, List, Optional
+
+from .base import BaseSearchProvider, SearchResult
+
+logger = logging.getLogger(__name__)
+
+
+_SELECT_SKILLS_SYSTEM = """你是一名情报研究员,需要从可用「技能」中挑出对写作任务有帮助的若干个,并按相关度从高到低排序。
+技能是一段封装好的指令 / 知识 / 工作流,每个技能有 name 和 description。
+
+【严禁思考】
+- 这是一个简单的匹配任务,禁止任何思考、分析、推理过程,禁止输出
标签。
+- 收到输入后立即直接输出最终 JSON,不得有任何前置文字。
+
+【输出要求】
+- 只输出一个 JSON 对象,不要 markdown 代码块。
+- 必须使用英文双引号。
+- selected_skills 中的 name 必须严格来自下方"可用技能"列表,不要编造,按相关度从高到低排序。
+- 如果用户输入里明确点名了某个技能(按 name 完全匹配),把它排在第一位。
+
+【完整示例】
+{"selected_skills": ["web-search", "knowledge-lookup"], "reason": "用户想检索互联网信息,web-search 最相关,知识库查询作为补充"}"""
+
+_SELECT_SKILLS_USER = """写作意图:{user_intent}
+用户补充输入:{user_query}
+检索关键词:{keywords}
+
+可用技能(共 {skill_count} 个):
+{skill_list}
+
+请从中按相关度从高到低选出最多 {max_select} 个技能。如果没有合适的技能,selected_skills 返回空数组。
+只输出 JSON:
+{{"selected_skills": ["name1", "name2"], "reason": "为什么选这些 + 排序依据"}}
+
+/no_think"""
+
+
+EmitStepFn = Callable[[dict], None]
+
+
+def _summarize_skill_content(content: str, limit: int = 500) -> str:
+ """从 SKILL.md 提取摘要:跳过 frontmatter,取正文开头 limit 字。"""
+ if not content:
+ return ""
+ body = content
+ m = re.match(r"^---\s*\n(.*?\n)---\s*\n(.*)$", content, flags=re.DOTALL)
+ if m:
+ body = m.group(2)
+ body = re.sub(r"^#\s+.*?\n", "", body.lstrip(), count=1)
+ body = body.strip()
+ if len(body) > limit:
+ return body[:limit].rstrip() + "…"
+ return body
+
+
+class SkillSearchProvider(BaseSearchProvider):
+ """从启用的 Skills 中筛选并返回作为素材。
+
+ 构造参数:
+ llm_select: 异步 LLM 调用 (system, user, fallback) -> dict。
+ emit_step: 可选;推送时间线步骤事件(list_skills / select_skills / invoke_skill)。
+ max_select: LLM 单次最多挑几个技能。
+ user_query: 用户在素材确认卡输入框写的自然语言(可能包含技能名 / 来源 / 补充意图)。
+ """
+
+ def __init__(
+ self,
+ llm_select: Callable,
+ *,
+ emit_step: Optional[EmitStepFn] = None,
+ max_select: int = 5,
+ user_query: str = "",
+ ) -> None:
+ self._llm_select = llm_select
+ self._emit_step = emit_step or (lambda _step: None)
+ self._max_select = max_select
+ self._user_query = (user_query or "").strip()
+ self._errors: list[str] = []
+ # 单次 researcher 调用内复用结果,避免每个 keyword 都问一次 LLM
+ self._selected_cache: list[dict] | None = None
+ self._enabled_skills: list | None = None
+ # 跨 keyword 调用时同一个 skill 只暴露一次
+ self._yielded_skills: set[str] = set()
+
+ @property
+ def errors(self) -> list[str]:
+ return list(self._errors)
+
+ def _load_enabled_skills(self) -> list:
+ if self._enabled_skills is not None:
+ return self._enabled_skills
+ try:
+ from deerflow.skills import get_or_new_skill_storage
+ storage = get_or_new_skill_storage()
+ skills = storage.load_skills(enabled_only=True)
+ except Exception as e:
+ logger.exception("ai_writing.skill_search: 加载启用技能失败")
+ self._errors.append(f"skill list error: {e}")
+ skills = []
+ self._enabled_skills = skills
+ self._emit_step({
+ "type": "list_skills",
+ "message": f"发现 {len(skills)} 个可用技能",
+ "skill_names": [s.name for s in skills],
+ })
+ return skills
+
+ async def _select_skills(self, query: str, user_intent: str) -> list[dict]:
+ """让 LLM 从启用的技能中按相关度排序选出若干个。同一次检索复用结果。"""
+ if self._selected_cache is not None:
+ return self._selected_cache
+
+ skills = self._load_enabled_skills()
+ if not skills:
+ self._selected_cache = []
+ return []
+
+ skill_list_text = "\n".join(
+ f"- {s.name}: {s.description or '(无描述)'}" for s in skills
+ )
+ try:
+ result = await self._llm_select(
+ _SELECT_SKILLS_SYSTEM,
+ _SELECT_SKILLS_USER.format(
+ user_intent=user_intent or query,
+ user_query=self._user_query or "(无)",
+ keywords=query,
+ skill_count=len(skills),
+ skill_list=skill_list_text,
+ max_select=self._max_select,
+ ),
+ fallback={"selected_skills": [s.name for s in skills[: self._max_select]]},
+ )
+ except Exception as e:
+ logger.exception("ai_writing.skill_search: LLM 选择技能失败")
+ self._errors.append(f"skill select error: {e}")
+ result = {"selected_skills": [s.name for s in skills[: self._max_select]]}
+
+ wanted_names = result.get("selected_skills") or []
+ if not isinstance(wanted_names, list):
+ wanted_names = []
+ by_name = {s.name: s for s in skills}
+ chosen: list[dict] = []
+ for name in wanted_names:
+ if not isinstance(name, str):
+ continue
+ sk = by_name.get(name.strip())
+ if sk:
+ chosen.append({"skill": sk, "reason": result.get("reason", "")})
+ if len(chosen) >= self._max_select:
+ break
+
+ self._emit_step({
+ "type": "select_skills",
+ "message": f"按相关度排序,候选 {len(chosen)} 个技能(将按顺序调用)",
+ "skill_names": [c["skill"].name for c in chosen],
+ "reason": result.get("reason", ""),
+ })
+ self._selected_cache = chosen
+ return chosen
+
+ @staticmethod
+ def _read_skill_content(skill) -> str:
+ try:
+ return skill.skill_file.read_text(encoding="utf-8")
+ except Exception as e:
+ logger.warning("ai_writing.skill_search: 读 SKILL.md 失败 %s: %s", skill.name, e)
+ return skill.description or ""
+
+ def _to_search_result(self, skill, reason: str) -> SearchResult:
+ raw = self._read_skill_content(skill)
+ summary = _summarize_skill_content(raw, limit=500)
+ category_value = skill.category.value if hasattr(skill.category, "value") else str(skill.category)
+ return SearchResult(
+ title=skill.name,
+ content=summary or (skill.description or skill.name),
+ source=f"技能库 / {category_value}",
+ published_at="",
+ url="",
+ relevance_score=1.0,
+ source_type="skill",
+ recUuid="",
+ extra_metadata={
+ "skill_name": skill.name,
+ "skill_description": skill.description or "",
+ "skill_path": skill.skill_path,
+ "container_path": skill.get_container_file_path(),
+ "category": category_value,
+ "select_reason": reason,
+ # 完整 SKILL.md 内容,供 writer_draft 节点按需注入到 prompt
+ "skill_full_content": raw,
+ },
+ )
+
+ async def search(
+ self,
+ query: str,
+ max_results: int = 5,
+ date_from: str = "",
+ date_to: str = "",
+ user_intent: str = "",
+ ) -> List[SearchResult]:
+ """按相关度顺序产出候选 skills 对应的 SearchResult。
+
+ 说明:每个 skill 直接产出一条 SearchResult。researcher 节点上游会按
+ skill_name 去重,所以同一个 SkillSearchProvider 多次调用本方法时不会
+ 产出重复 skill 素材(_yielded_skills 兜底)。
+ """
+ chosen = await self._select_skills(query, user_intent=user_intent)
+ if not chosen:
+ return []
+
+ results: List[SearchResult] = []
+ for entry in chosen:
+ sk = entry["skill"]
+ if sk.name in self._yielded_skills:
+ continue
+ self._emit_step({
+ "type": "invoke_skill",
+ "message": f"调用技能:{sk.name}",
+ "skill_name": sk.name,
+ "matched_count": len(self._yielded_skills) + 1,
+ })
+ results.append(self._to_search_result(sk, entry.get("reason", "")))
+ self._yielded_skills.add(sk.name)
+ if len(results) >= max_results:
+ break
+ return results
+
+ async def health_check(self) -> bool:
+ try:
+ return bool(self._load_enabled_skills())
+ except Exception:
+ return False
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
index a64c3321..2420ef50 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
@@ -3,21 +3,43 @@ import operator
# ── 素材 ──────────────────────────────────────────────────────────────────────
-class Material(TypedDict):
+class Material(TypedDict, total=False):
id: str
title: str
content: str # 截断到 500 字
source: str
published_at: str
relevance_score: float
- source_type: str # "internet" | "intranet"
+ source_type: str # "internet" | "intranet" | "skill"
url: str
recUuid: str
+ # skill 类素材附带的元信息(skill_name / container_path / category),其他类型为空
+ extra_metadata: dict
-class MaterialPackage(TypedDict):
+class SkillStep(TypedDict, total=False):
+ """技能检索过程的单步记录,用于前端时间线展示。
+
+ type=match_skills:根据用户输入流式分析有没有匹配技能(无 skill_names)
+ type=list_skills:列出可用技能
+ type=select_skills:LLM 从中选出的相关技能
+ type=invoke_skill:实际调用某一个技能检索
+ type=stop_early:达到素材阈值,提前停止后续技能调用
+ """
+ type: str
+ message: str
+ skill_names: List[str]
+ skill_name: str
+ matched_count: int
+ reason: str
+
+class MaterialPackage(TypedDict, total=False):
keywords: List[str]
materials: List[Material]
summary: str
+ # 是否本次素材来自技能检索(覆盖式替换 web 检索)
+ skill_search_used: bool
+ # 技能检索的步骤记录(前端展示"使用了 X 技能"卡片)
+ skill_search_steps: List[SkillStep]
# ── 大纲 ──────────────────────────────────────────────────────────────────────
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
@@ -72,10 +94,13 @@ class ReviewResult(TypedDict):
# ── 用户干预 ──────────────────────────────────────────────────────────────────
-class UserInterventionPayload(TypedDict):
+class UserInterventionPayload(TypedDict, total=False):
action: str
approved_material_ids: List[str]
extra_keywords: List[str]
+ # PAUSE-1 素材确认 / enable_skill_search 共用:用户在单输入框里填的自然语言
+ # (可能是要使用的技能名、想检索的来源、或者一段补充检索意图)。
+ user_query: str
edited_outline: Optional[Outline]
outline_feedback: str
user_revision_notes: str