作家写作
This commit is contained in:
1 parent
1a122eb7fd
commit
07ad87365f
7 files changed
+380
-228
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -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<Set<string>>(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) => (
|
||||
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
|
||||
{items.map((item) => {
|
||||
const hasChildren = !!item.children?.length;
|
||||
const isExpanded = expandedIds.has(item.id);
|
||||
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sidebar-flyout-node sidebar-flyout-node--level-${level} ${hasChildren ? "sidebar-flyout-node--has-children" : ""} ${isExpanded ? "sidebar-flyout-node--expanded" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-flyout-item ${isSelected ? "sidebar-flyout-item--active" : ""} ${hasChildren ? "sidebar-flyout-item--parent" : "sidebar-flyout-item--leaf"}`}
|
||||
onClick={() => handleMenuItemClick(item)}
|
||||
>
|
||||
{hasChildren ? <span className="sidebar-flyout-arrow" aria-hidden="true" /> : null}
|
||||
<span className="sidebar-flyout-label">{item.label}</span>
|
||||
</button>
|
||||
{hasChildren && isExpanded ? renderFlyoutMenu(item.children ?? [], level + 1) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
@@ -332,11 +400,18 @@ export function PageSidebarDark({
|
||||
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
|
||||
<div className="sidebar-contnet">
|
||||
<div className="sidebar-contents">
|
||||
<div className="item-nav-content"></div>
|
||||
<div className="item-nav-content">
|
||||
{activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null}
|
||||
</div>
|
||||
{visibleMenuItems.map((item) => (
|
||||
<div key={item.id} className={`item-nav ${item.id === 'qa-center' ? 'item-nav-active' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""}`}
|
||||
onClick={() => handleTopItemClick(item)}
|
||||
>
|
||||
<span className="item-nav__label">{item.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<SkillSearchStep['type'], typeof Sparkles> = {
|
||||
list_skills: ListChecks,
|
||||
select_skills: Sparkles,
|
||||
invoke_skill: Zap,
|
||||
skill_result: CheckCircle2,
|
||||
stop_early: CircleStop,
|
||||
};
|
||||
|
||||
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
|
||||
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 (
|
||||
<li key={idx} className="flex items-start gap-2 text-[11px]">
|
||||
<span className="shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 font-medium text-violet-700 dark:text-violet-300">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 font-medium',
|
||||
isStopEarly
|
||||
? 'text-amber-700 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-violet-700 dark:text-violet-300',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3 shrink-0" />
|
||||
<span>{label}</span>
|
||||
{step.skillName && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+143
-90
@@ -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]),
|
||||
|
||||
+33
-125
@@ -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。
|
||||
|
||||
【严禁思考】
|
||||
- 这是一个简单的匹配任务,禁止任何思考、分析、推理过程,禁止输出 <think></think> 标签。
|
||||
@@ -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
|
||||
Reference in new issue
Block a user