diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 263fc14d..f628f7b9 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -16,7 +16,9 @@ .item-nav{ width:100%; padding:10px 0; + border:0; border-bottom:1px solid rgba(85, 166, 254, 0.2); + background:transparent; font-family:'Noto Sans S Chinese', sans-serif; font-weight:500; color:rgba(255, 255, 255, 0.8); @@ -44,7 +46,7 @@ .item-nav-content{ width: 155px; - height: 970px; + min-height: 970px; background: rgba(3, 38, 80, 0.8); border: 1px solid; border-color: rgba(43, 143, 254, 0.6); @@ -54,6 +56,8 @@ left: 97px; top: 0; z-index: 1000; + padding: 14px 13px 18px 16px; + overflow-y:auto; } .item-nav-active{ @@ -81,4 +85,87 @@ height:45px; background: url('../assets/icon/nav-line-logo.svg') no-repeat; background-size: cover; -} \ No newline at end of file +} + +.sidebar-flyout-list{ + display:flex; + flex-direction:column; + gap:2px; + position:relative; +} + +.sidebar-flyout-list--level-1, +.sidebar-flyout-list--level-2, +.sidebar-flyout-list--level-3{ + margin-left:19px; + padding-left:14px; +} + +.sidebar-flyout-node{ + position:relative; +} + +.sidebar-flyout-node--has-children.sidebar-flyout-node--expanded > .sidebar-flyout-list::before{ + content:''; + position:absolute; + left:0; + top:0; + width:1px; + height:100%; + background:rgba(43, 143, 254, 0.22); +} + +.sidebar-flyout-item{ + width:100%; + min-height:34px; + display:flex; + align-items:center; + gap:9px; + border:0; + background:transparent; + padding:3px 0; + font-family:Noto Sans S Chinese; + font-size:18px; + line-height:28px; + font-weight:500; + letter-spacing:0; + color:rgba(255, 255, 255, 0.8); + text-align:left; + cursor:pointer; + transition:color 0.2s ease, text-shadow 0.2s ease; +} + +.sidebar-flyout-item:hover, +.sidebar-flyout-item--active{ + color:#48ddff; + text-shadow:0 0 8px rgba(72, 221, 255, 0.25); +} + +.sidebar-flyout-item--leaf{ + justify-content:center; + padding-left:0; +} + +.sidebar-flyout-list--level-1 > .sidebar-flyout-node > .sidebar-flyout-item--leaf, +.sidebar-flyout-list--level-2 > .sidebar-flyout-node > .sidebar-flyout-item--leaf, +.sidebar-flyout-list--level-3 > .sidebar-flyout-node > .sidebar-flyout-item--leaf{ + padding-left:0; +} + +.sidebar-flyout-arrow{ + width:9px; + height:9px; + flex:0 0 9px; + background:url('../assets/icon/down-logo.svg') center / contain no-repeat; + transform:rotate(-90deg); + transition:transform 0.2s ease; +} + +.sidebar-flyout-node--expanded > .sidebar-flyout-item > .sidebar-flyout-arrow{ + transform:rotate(0deg); +} + +.sidebar-flyout-label{ + flex:1; + white-space:nowrap; +} diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index dd841c64..aa18608f 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { ElementType } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { @@ -285,7 +285,10 @@ export function PageSidebarDark({ const { theme } = useTheme(); const isDark = theme === "dark"; const isAdmin = getStoredAuth()?.system_role === "admin"; - const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin); + const visibleMenuItems = useMemo( + () => filterMenuItemsByRole(menuItems, !!isAdmin), + [isAdmin], + ); const { t } = useI18n(); const { data: unreadCount = 0 } = useUnreadCount(); const [expandedIds, setExpandedIds] = useState>(defaultExpandedIds); @@ -306,6 +309,26 @@ export function PageSidebarDark({ setSettingsOpen(true); }; + const hasActiveChild = (item: MenuItem): boolean => { + if (item.path && pathname === item.path) { + return true; + } + + return item.children?.some(hasActiveChild) ?? false; + }; + + const initialActiveTopItem = visibleMenuItems.find(hasActiveChild) ?? visibleMenuItems[0]; + const [activeTopId, setActiveTopId] = useState(initialActiveTopItem?.id ?? "qa-center"); + + useEffect(() => { + const matchedTopItem = visibleMenuItems.find(hasActiveChild); + if (matchedTopItem) { + setActiveTopId(matchedTopItem.id); + } + }, [pathname, visibleMenuItems]); + + const activeTopItem = visibleMenuItems.find((item) => item.id === activeTopId) ?? initialActiveTopItem; + const toggleExpanded = (id: string) => { setExpandedIds((prev) => { const next = new Set(prev); @@ -318,6 +341,51 @@ export function PageSidebarDark({ }); }; + const handleTopItemClick = (item: MenuItem) => { + setActiveTopId(item.id); + + if (!item.children?.length && item.path) { + navigate(item.path); + } + }; + + const handleMenuItemClick = (item: MenuItem) => { + if (item.children?.length) { + toggleExpanded(item.id); + return; + } + + if (item.path) { + navigate(item.path); + } + }; + + const renderFlyoutMenu = (items: MenuItem[], level = 0) => ( +
+ {items.map((item) => { + const hasChildren = !!item.children?.length; + const isExpanded = expandedIds.has(item.id); + const isSelected = item.path ? pathname === item.path : hasActiveChild(item); + + return ( +
+ + {hasChildren && isExpanded ? renderFlyoutMenu(item.children ?? [], level + 1) : null} +
+ ); + })} +
+ ); return ( <>
-
+
+ {activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null} +
{visibleMenuItems.map((item) => ( -
+
+ ))}
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 9e0b9f86..526fedce 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 } from 'lucide-react'; +import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { SkillSearchStep } from '../../types/ai-writing'; import { CollapseTransition } from './CollapseTransition'; @@ -14,12 +14,16 @@ const STEP_ICON: Record = { list_skills: ListChecks, select_skills: Sparkles, invoke_skill: Zap, + skill_result: CheckCircle2, + stop_early: CircleStop, }; const STEP_LABEL: Record = { list_skills: '列出可用技能', select_skills: '筛选相关技能', invoke_skill: '调用技能', + skill_result: '检索结果', + stop_early: '提前停止', }; export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) { @@ -55,13 +59,33 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) { {steps.map((step, idx) => { const Icon = STEP_ICON[step.type] ?? Sparkles; const label = STEP_LABEL[step.type] ?? step.type; + const isStopEarly = step.type === 'stop_early'; + const isResult = step.type === 'skill_result'; return (
  • - + {idx + 1}
    -
    +
    {label} {step.skillName && ( diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index 58a04a53..798eb502 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -767,7 +767,9 @@ export function useAIWriting() { // 乐观更新到下一阶段,跟老 hook 行为对齐 let nextStatus: WritingStatus = state.status; if (pausePoint === 'material_confirm') { - nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline'; + // re_search / enable_skill_search 都回到 researching;confirm 才进大纲 + nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search') + ? 'researching' : 'writing_outline'; } else if (pausePoint === 'outline_confirm') { nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft'; } else if (pausePoint === 'draft_confirm') { @@ -788,6 +790,7 @@ export function useAIWriting() { }; if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds; if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords; + if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery; if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline; if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback; if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes; diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 28a4a567..b1c2faa5 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -17,9 +17,11 @@ export interface AIMaterial { // 技能检索过程的单步记录(前端时间线展示"使用了 X 技能") // list_skills:列出全部 enabled 技能 // select_skills:LLM 按相关度排序的候选 -// invoke_skill:实际按顺序调用某个技能 +// invoke_skill:开始调用某个技能去检索 +// skill_result:某个技能检索返回了 N 条素材 +// stop_early:累计素材达到上限,停止后续技能 export interface SkillSearchStep { - type: 'list_skills' | 'select_skills' | 'invoke_skill'; + type: 'list_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early'; message: string; skillNames?: string[]; skillName?: 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 a381755c..ed0b17e0 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,8 @@ from typing import List from langchain_core.runnables import RunnableConfig -from ..search import get_search_provider, SearchResult, SkillSearchProvider +from ..search import get_search_provider, SearchResult +from ..search.skill_search import SkillPicker from ..state import AIWritingState, Material, MaterialPackage from ..prompts.researcher_prompts import ( EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER, @@ -135,10 +136,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict writer({"type": "research_progress", "message": message}) # Step 1: 提取关键词 - kw_result: dict = {} # 走"用户输入 / 追加关键词"分支时跳过 LLM 调用,notice 拼接需要兜底值 - if is_user_query_search: + kw_result: dict = {} # 走"用户输入 / 追加 / 技能"分支时跳过 LLM 调用,notice 拼接需要兜底值 + if is_skill_search: + # 技能模式:每个 skill 会自己拼 query(user_query + skill.description),这里不用 LLM 生成关键词 + keywords: list[str] = [user_query or intent] + elif is_user_query_search: # 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分 - keywords: list[str] = [user_query] + keywords = [user_query] _emit(f"按你的输入「{user_query[:30]}」检索…") elif is_append_only: # 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM @@ -164,57 +168,115 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict keywords = list(dict.fromkeys(keywords + extra_keywords)) # Step 2: 搜索 - # 技能检索:用 SkillSearchProvider 替代 web/intranet;步骤事件单独走 skill_search_step 通道 + # 技能检索:技能不是素材,是"检索策略 / 视角"。流程: + # a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件) + # b. 按顺序遍历 skills:每个 skill 用其 description 让 LLM 生成针对性关键词 + # c. 调真实 web/intranet provider 执行检索,推 invoke_skill + skill_result 事件 + # d. 累积素材到 max_materials 即 break,推 stop_early skill_steps: list[dict] = [] + search_errors: list[str] = [] + + 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) 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( + # ── 技能编排检索 ── + picker = SkillPicker( llm_select=_llm_select, emit_step=_emit_skill_step, - max_select=max(1, min(int(max_materials), 10)), + max_select=5, user_query=user_query, ) + selected = await picker.pick(user_intent=intent) + search_errors.extend(picker.errors) + + # 真实 web/intranet provider(按 config.yaml 配置) + web_provider = get_search_provider(ai_writing_config) + + # 每个 skill 尽量多取(不分摊),第一个就达到 max_materials 即可早停; + # 这才贴近"第一个技能够了就停,否则下一个补充"的语义。 + per_skill = max(1, max_materials) + raw_materials_pre: list[Material] = [] + seen_urls_pre: set[str] = set() + stopped_early = False + + for entry in selected: + sk = entry["skill"] + # 用 LLM 把 user_query + skill.description 转成针对性关键词 + _emit_skill_step({ + "type": "invoke_skill", + "message": f"使用技能「{sk.name}」开始检索…", + "skill_name": sk.name, + }) + skill_query = f"{user_query} {sk.description or ''}".strip() or intent + try: + res = await web_provider.search(skill_query, max_results=per_skill) + except Exception as e: + logger.warning("skill[%s] search failed: %s", sk.name, e) + search_errors.append(f"skill[{sk.name}]: {e}") + res = [] + + # 去重累积 + new_count = 0 + for r in res or []: + if r.url and r.url in seen_urls_pre: + continue + if r.url: + seen_urls_pre.add(r.url) + raw_materials_pre.append(Material( + id=f"m{len(raw_materials_pre)+1:03d}", + title=r.title, + content=r.content[:500], + source=f"{r.source}(via 技能 {sk.name})", + published_at=r.published_at, + relevance_score=r.relevance_score, + source_type=r.source_type, + url=r.url, + recUuid=r.recUuid, + )) + new_count += 1 + + _emit_skill_step({ + "type": "skill_result", + "message": f"技能「{sk.name}」检索到 {new_count} 条新素材", + "skill_name": sk.name, + "matched_count": new_count, + }) + + # 早停:累积素材已达上限 + if len(raw_materials_pre) >= max_materials: + stopped_early = True + _emit_skill_step({ + "type": "stop_early", + "message": f"已获取 {len(raw_materials_pre)} 条素材,达到上限,停止调用后续技能", + "matched_count": len(raw_materials_pre), + }) + break + + # 技能模式不再走下面的并发/合并逻辑,all_results_nested 留空、跳过 web 通用搜索 + all_results_nested: list = [] + # 把已收集到的素材挂在 raw_materials_pre,下方合并阶段直接挪过去 else: + # ── 常规 web/intranet 模式 ── provider = get_search_provider(ai_writing_config) - # 按用户指定的关键词数量截断。 - # 用户输入 / 追加模式:keywords 本身就是用户填的,全部检索不截断。 - # 其他模式:受 keyword_count 限制,但 extra_keywords 在内时上限放宽到 keywords 总数。 - if is_user_query_search or is_append_only: - search_keywords = keywords - else: - search_limit = keyword_count - if extra_keywords: - search_limit = max(search_limit, len(keywords)) - search_keywords = keywords[:search_limit] - total = len(search_keywords) - # 每个检索词分到的检索条数 = 总量上限 / 检索词数(向上取整) - per_keyword = _per_keyword_limit(max_materials, total) - _emit(f"检索词已生成,开始检索 {total} 个方向…") + # 按用户指定的关键词数量截断。 + # 用户输入 / 追加模式:keywords 本身就是用户填的,全部检索不截断。 + if is_user_query_search or is_append_only: + search_keywords = keywords + else: + search_limit = keyword_count + if extra_keywords: + search_limit = max(search_limit, len(keywords)) + search_keywords = keywords[:search_limit] + total = len(search_keywords) + per_keyword = _per_keyword_limit(max_materials, total) + _emit(f"检索词已生成,开始检索 {total} 个方向…") - # 技能模式:串行调用 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]: @@ -230,59 +292,48 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict search_tasks = [_search_one(kw) for kw in search_keywords] all_results_nested = await asyncio.gather(*search_tasks, return_exceptions=True) + # provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户 + search_errors.extend(getattr(provider, "errors", [])) # 合并去重 # - 首次检索 / 普通 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] = [] + # - 技能检索 is_skill_search:覆盖式替换,旧素材完全丢弃(编排循环已经把素材累积到 raw_materials_pre) existing_pkg = state.get("material_package") - 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", "")) - raw_materials.append(m) + if is_skill_search: + # 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials + raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined] + else: + seen_urls: set[str] = set() + raw_materials = [] + preserve_existing = existing_pkg and (is_supplement or is_append_only) + if preserve_existing: + for m in existing_pkg.get("materials", []): + seen_urls.add(m.get("url", "")) + raw_materials.append(m) - search_errors: list[str] = [] - for results in all_results_nested: - if isinstance(results, Exception): - logger.warning("search failed: %s", results) - search_errors.append(f"{type(results).__name__}: {results}") - continue - for r in results: - # 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: + for results in all_results_nested: + if isinstance(results, Exception): + logger.warning("search failed: %s", results) + search_errors.append(f"{type(results).__name__}: {results}") continue - else: - seen_urls.add(r.url) - mat: Material = Material( - id=f"m{len(raw_materials)+1:03d}", - title=r.title, - content=r.content[:500], - source=r.source, - published_at=r.published_at, - relevance_score=r.relevance_score, - 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", [])) + for r in results: + if r.url and r.url in seen_urls: + continue + if r.url: + seen_urls.add(r.url) + raw_materials.append(Material( + id=f"m{len(raw_materials)+1:03d}", + title=r.title, + content=r.content[:500], + source=r.source, + published_at=r.published_at, + relevance_score=r.relevance_score, + source_type=r.source_type, + url=r.url, + recUuid=r.recUuid, + )) # Step 3: 相关度排序(如素材较多,让 LLM 排序) _emit("检索完成,正在筛选排序素材…") @@ -343,7 +394,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict if search_errors: uniq = list(dict.fromkeys(search_errors)) # 解析失败时收集到的接口原始响应体,前端折叠展示便于定位 - raw_dumps = list(dict.fromkeys(getattr(provider, "raw_responses", []) or [])) + # (技能模式下 provider 局部变量没暴露,所以 raw_dumps 兜底为空列表) + provider_obj = locals().get("provider") or locals().get("web_provider") + raw_dumps = list(dict.fromkeys(getattr(provider_obj, "raw_responses", []) or [])) notices.append(_notice( "ES 波动导致检索异常", ";".join(uniq[:3]), 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 index 2a08a773..9095b94b 100644 --- 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 @@ -1,26 +1,27 @@ -"""技能检索 Provider:把已启用的 Skill 当作可检索的素材源。 +"""技能选择器:根据用户意图 + 输入,从启用的 Skills 里挑出按相关度排序的若干个。 -流程(保持简单,对应素材确认卡上的"技能检索"按钮): +注意:这个模块**只负责选择技能,不直接产出素材**。 +真正的素材产出由 researcher 节点用每个 skill 的描述去编排真实 web/intranet 检索。 + +流程(对应素材确认卡上的"技能检索"按钮): 1) list_skills 事件 —— 列出当前启用的全部 skill 2) select_skills 事件 —— LLM 根据用户输入 + 写作意图按相关度选出若干个 -3) invoke_skill 事件 —— 按相关度从高到低顺序产出 SearchResult -输出格式严格对齐 BaseSearchProvider 的 SearchResult,让 researcher 节点 -不需要分支处理,下游 Material 字段同构。 +下游 researcher 节点拿到 selected_skills 后,**按顺序**: +- 用 skill.description + user_query 生成针对性关键词 +- 调真实 web search provider 执行 +- 累积素材达到阈值就停(推 stop_early) """ 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。 +_SELECT_SKILLS_SYSTEM = """你是一名情报研究员,需要从可用「技能」中挑出对当前检索任务最有帮助的若干个,并按相关度从高到低排序。 +技能是一段封装好的指令 / 知识 / 检索策略,每个技能有 name 和 description。 【严禁思考】 - 这是一个简单的匹配任务,禁止任何思考、分析、推理过程,禁止输出 标签。 @@ -31,18 +32,18 @@ _SELECT_SKILLS_SYSTEM = """你是一名情报研究员,需要从可用「技 - 必须使用英文双引号。 - selected_skills 中的 name 必须严格来自下方"可用技能"列表,不要编造,按相关度从高到低排序。 - 如果用户输入里明确点名了某个技能(按 name 完全匹配),把它排在第一位。 +- 至少选 1 个,最多选 {max_select} 个。 【完整示例】 -{"selected_skills": ["web-search", "knowledge-lookup"], "reason": "用户想检索互联网信息,web-search 最相关,知识库查询作为补充"}""" +{{"selected_skills": ["platform-sales-data", "industry-report"], "reason": "用户想查各平台销量,平台销量技能最相关"}}""" _SELECT_SKILLS_USER = """写作意图:{user_intent} 用户补充输入:{user_query} -检索关键词:{keywords} 可用技能(共 {skill_count} 个): {skill_list} -请从中按相关度从高到低选出最多 {max_select} 个技能。如果没有合适的技能,selected_skills 返回空数组。 +请从中按相关度从高到低选出最多 {max_select} 个技能。 只输出 JSON: {{"selected_skills": ["name1", "name2"], "reason": "为什么选这些 + 排序依据"}} @@ -52,27 +53,12 @@ _SELECT_SKILLS_USER = """写作意图:{user_intent} 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 中筛选并返回作为素材。 +class SkillPicker: + """从启用的 Skills 中选择按相关度排序的若干个供下游编排使用。 构造参数: llm_select: 异步 LLM 调用 (system, user, fallback) -> dict。 - emit_step: 可选;推送时间线步骤事件(list_skills / select_skills / invoke_skill)。 + emit_step: 可选;推送时间线步骤事件(list_skills / select_skills)。 max_select: LLM 单次最多挑几个技能。 user_query: 用户在素材确认卡输入框写的自然语言(可能包含技能名 / 来源 / 补充意图)。 """ @@ -90,19 +76,12 @@ class SkillSearchProvider(BaseSearchProvider): 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() @@ -111,7 +90,6 @@ class SkillSearchProvider(BaseSearchProvider): 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)} 个可用技能", @@ -119,14 +97,15 @@ class SkillSearchProvider(BaseSearchProvider): }) 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 - + async def pick(self, user_intent: str) -> List[dict]: + """选择并排序候选技能,返回 [{skill, reason}, ...],列表中的 skill 按相关度降序。""" skills = self._load_enabled_skills() if not skills: - self._selected_cache = [] + self._emit_step({ + "type": "select_skills", + "message": "当前没有启用的技能可用", + "skill_names": [], + }) return [] skill_list_text = "\n".join( @@ -134,11 +113,10 @@ class SkillSearchProvider(BaseSearchProvider): ) try: result = await self._llm_select( - _SELECT_SKILLS_SYSTEM, + _SELECT_SKILLS_SYSTEM.format(max_select=self._max_select), _SELECT_SKILLS_USER.format( - user_intent=user_intent or query, + user_intent=user_intent or "(无明确写作意图)", user_query=self._user_query or "(无)", - keywords=query, skill_count=len(skills), skill_list=skill_list_text, max_select=self._max_select, @@ -154,95 +132,25 @@ class SkillSearchProvider(BaseSearchProvider): if not isinstance(wanted_names, list): wanted_names = [] by_name = {s.name: s for s in skills} + reason = result.get("reason", "") or "" 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 sk and sk.name not in {c["skill"].name for c in chosen}: + chosen.append({"skill": sk, "reason": reason}) if len(chosen) >= self._max_select: break self._emit_step({ "type": "select_skills", - "message": f"按相关度排序,候选 {len(chosen)} 个技能(将按顺序调用)", + "message": f"按相关度排序,候选 {len(chosen)} 个技能(将按顺序使用)", "skill_names": [c["skill"].name for c in chosen], - "reason": result.get("reason", ""), + "reason": 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 +# 保留旧名字让 search/__init__.py 的导出不报错;研究节点不再用它 +SkillSearchProvider = SkillPicker