From a9332c0ab083070915bfe78f01983157677181bf Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 29 May 2026 15:22:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=80=E8=83=BD=E8=81=94=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/components/page-sdebar.css | 223 +++++++++--------- .../components/ai-writing/AIWritingPanel.tsx | 13 +- .../ai-writing/SkillSelectStreamingView.tsx | 158 +++++++++++++ .../open-canvas/hooks/useAIWritingStream.ts | 22 +- .../src/open-canvas/types/ai-writing.ts | 4 + .../agents/ai_writing/nodes/researcher.py | 7 +- .../agents/ai_writing/search/skill_search.py | 87 +++++-- 7 files changed, 369 insertions(+), 145 deletions(-) create mode 100644 frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index f628f7b9..29b4e477 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -1,11 +1,11 @@ -.sidebar-contents{ - width:98px; - min-height:986px; - background:rgba(3, 38, 80, 0.8); - border:1px solid; - border-color:rgba(43, 143, 254, 0.6); - border-radius:4px; - backdrop-filter:blur(2px); +.sidebar-contents { + width: 98px; + min-height: 986px; + background: rgba(3, 38, 80, 0.8); + border: 1px solid; + border-color: rgba(43, 143, 254, 0.6); + border-radius: 4px; + backdrop-filter: blur(2px); display: flex; flex-direction: column; align-items: center; @@ -13,38 +13,44 @@ position: relative; } -.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); - font-size:20px; - line-height:26px; - text-align:center; - cursor:pointer; - transition:background 0.2s ease, color 0.2s ease; +.item-nav { + width: 100%; + height: 70px; + background: #ffffff1a; + border-color: #fefafa4d; + margin-top: 13px; + font-family: Noto Sans S Chinese; + font-weight: 550; + color: #fff; + font-size: 20px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: pointer; + background: #000c1c26; + border: 1px solid; + border-color: #55a6fe33; + box-shadow: 0 0 20px #00b9ff26 inset; + position: relative; } -.item-nav:hover{ - background:rgba(85, 166, 254, 0.12); - color:#ffffff; +.item-nav:hover { + background: rgba(85, 166, 254, 0.12); + color: #ffffff; } -.item-nav__label{ - display:block; - width:3em; - margin:0 auto; - padding-left:6px; - box-sizing:border-box; - letter-spacing:6px; - word-break:break-all; +.item-nav__label { + display: block; + width: 3em; + margin: 0 auto; + padding-left: 6px; + box-sizing: border-box; + letter-spacing: 6px; + word-break: break-all; } -.item-nav-content{ +.item-nav-content { width: 155px; min-height: 970px; background: rgba(3, 38, 80, 0.8); @@ -57,115 +63,110 @@ top: 0; z-index: 1000; padding: 14px 13px 18px 16px; - overflow-y:auto; + overflow-y: auto; } -.item-nav-active{ - color:#ffffff; +.item-nav-active { + color: #ffffff; + background: linear-gradient(134.25deg, + rgba(0, 98, 217, 0.59) 2.28%, + rgba(0, 98, 217, 0.3) 99.59%) !important; + background-size: cover; + border: 1px solid; + border-color: rgba(255, 255, 255, 0.2); position: relative; } -.item-nav-active::before{ +.item-nav-active::after { content: ''; position: absolute; left: 0; - top: 0; - width:84.5px; - height:70px; - background: url('../assets/icon/nav-active.svg') no-repeat; - background-size: contain; -} - -.item-nav-active::after{ - content: ''; - position: absolute; - left: 0; - top: 25px; - width:77px; - height:45px; + top: 24px; + width: 77px; + 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 { + 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-list--level-3 { + margin-left: 19px; + padding-left: 14px; } -.sidebar-flyout-node{ - position:relative; +.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-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 { + 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--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-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-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-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-node--expanded>.sidebar-flyout-item>.sidebar-flyout-arrow { + transform: rotate(0deg); } -.sidebar-flyout-label{ - flex:1; - white-space:nowrap; -} +.sidebar-flyout-label { + flex: 1; + white-space: nowrap; +} \ No newline at end of file diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index e96ad767..9efa88b2 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -39,6 +39,7 @@ import { AIWritingForm } from './AIWritingForm'; import { AIWritingTimeline } from './AIWritingTimeline'; import { InterventionCard } from './InterventionCard'; import { SkillStepsCard } from './SkillStepsCard'; +import { SkillSelectStreamingView } from './SkillSelectStreamingView'; import { WritingSkeletonCard } from './WritingSkeletonCard'; import { DraftStreamingView } from './DraftStreamingView'; import { IntentResultCard } from './IntentResultCard'; @@ -447,6 +448,14 @@ export function AIWritingPanel({ /> )} + {/* 技能选择 LLM 流式打字(含 思考分离),select_skills 完成后自动清空 */} + {state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && ( + + )} + {/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */} {state.status === 'researching' && state.skillSearchSteps.length > 0 && (
@@ -458,8 +467,8 @@ export function AIWritingPanel({ state.status !== 'writing_draft' && !(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) && !(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && - /* 技能检索已经在流式展示步骤,不需要再叠加 skeleton */ - !(state.status === 'researching' && state.skillSearchSteps.length > 0) && + /* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */ + !(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) && /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ !state.intentStreamingText && !state.intentThinkingText && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx new file mode 100644 index 00000000..ca8bd908 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx @@ -0,0 +1,158 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Sparkles, ChevronDown, ChevronRight, Brain, Zap } from 'lucide-react'; + +interface SkillSelectStreamingViewProps { + text: string; + thinkingText?: string; +} + +/** + * 从流式 JSON 文本里增量提取已经识别出的技能名。 + * + * LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`, + * 我们不等整段 JSON 解析完,逐字符扫描"已闭合"的双引号字符串: + * - 找到 `"selected_skills"`(或末尾不完整时回退兜底)后取后续内容 + * - 在该位置之后的 `[ ... ]` 内逐个匹配 `"..."` + * - 只取**已闭合**的字符串(结尾必须是 `"`),确保不把正在打字的半截名字露出来 + */ +function extractStreamingSkillNames(text: string): string[] { + if (!text) return []; + // 定位 selected_skills 的值起点(容错:允许中英文引号 / 不同空白) + const keyMatch = text.match(/["']?selected_skills["']?\s*:\s*\[/); + if (!keyMatch) return []; + const start = keyMatch.index! + keyMatch[0].length; + // 截取数组段(找到对应的 ],或者文本结尾) + const rest = text.slice(start); + const endIdx = rest.indexOf(']'); + const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest; + // 只提取已经"闭合"的字符串:[^"\\]*(?:\\.[^"\\]*)* 处理转义引号 + const names: string[] = []; + const re = /"((?:[^"\\]|\\.)*)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(arrBody)) !== null) { + const name = m[1].trim(); + if (name) names.push(name); + } + return names; +} + +/** + * 技能选择 LLM 流式打字机视图。 + * + * - 思考过程( 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致) + * - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义) + * - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标 + */ +export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) { + const preRef = useRef(null); + const thinkRef = useRef(null); + const [thinkOpen, setThinkOpen] = useState(true); + + // 实时从流式 JSON 中增量解析出已识别的技能名 + const streamingSkills = useMemo(() => extractStreamingSkillNames(text), [text]); + + useEffect(() => { + if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight; + }, [text]); + + useEffect(() => { + if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight; + }, [thinkingText, thinkOpen]); + + if (!text && !thinkingText) return null; + + return ( +
+ {thinkingText && ( +
+ + {thinkOpen && ( +
+              {thinkingText}
+              {!text && (
+                
+              )}
+            
+ )} +
+ )} + + {text && ( +
+
+ + + 正在筛选相关技能 + {streamingSkills.length > 0 && ( + + · 已识别 {streamingSkills.length} 个 + + )} + + + {[0, 1, 2].map((i) => ( + + ))} + +
+ + {/* 已识别的技能名:每个新出现的 badge 会 fade-in,给用户即时反馈 */} + {streamingSkills.length > 0 && ( +
+ {streamingSkills.map((name, idx) => ( + + + {name} + + ))} +
+ )} + +
+            {text}
+            
+          
+
+ )} + + +
+ ); +} diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index 798eb502..0cd2943e 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -73,6 +73,8 @@ const INITIAL_STATE: AIWritingState = { parsedIntent: null, researchProgress: '', skillSearchSteps: [], + skillSelectStreamingText: '', + skillSelectThinkingText: '', streamingReviewItems: [], revisionSummary: null, pendingRevisionContext: null, @@ -107,7 +109,7 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState { ...prev, currentPause: null, status: ns, - ...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [] } : {}), + ...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}), ...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}), ...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}), ...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}), @@ -196,7 +198,21 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState case 'skill_search_step': { const step = event.skillStep; if (!step) return base; - return { ...base, skillSearchSteps: [...prev.skillSearchSteps, step] }; + // select_skills 步骤到达 = LLM 选择完成,清空流式打字缓冲 + const clearStreaming = step.type === 'select_skills' + ? { skillSelectStreamingText: '', skillSelectThinkingText: '' } + : {}; + return { ...base, skillSearchSteps: [...prev.skillSearchSteps, step], ...clearStreaming }; + } + + case 'skill_select_chunk': { + const text = (event as unknown as { text: string }).text ?? ''; + const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false; + return { + ...prev, + skillSelectStreamingText: isThinking ? prev.skillSelectStreamingText : prev.skillSelectStreamingText + text, + skillSelectThinkingText: isThinking ? prev.skillSelectThinkingText + text : prev.skillSelectThinkingText, + }; } case 'materials_ready': { @@ -470,7 +486,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | 'intent_chunk', 'intent_ready', 'research_progress', 'outline_chunk', 'draft_chunk', 'review_chunk', 'review_item_done', - 'skill_search_step', + 'skill_search_step', 'skill_select_chunk', ]; if (!allowed.includes(type as ProgressEventType)) return null; diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index b1c2faa5..2a1e2936 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -157,6 +157,7 @@ export type ProgressEventType = | 'resumed' | 'notice' | 'skill_search_step' + | 'skill_select_chunk' | 'done' | 'error'; @@ -316,6 +317,9 @@ export interface AIWritingState { researchProgress: string; // 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线) skillSearchSteps: SkillSearchStep[]; + // 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空 + skillSelectStreamingText: string; + skillSelectThinkingText: string; // 流式审核(逐条到达) streamingReviewItems: StreamingReviewItem[]; // 修改摘要 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 ed0b17e0..1f219e8a 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 @@ -180,13 +180,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict 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: # ── 技能编排检索 ── + # 技能选择走流式 LLM(推 skill_select_chunk)+ 分离,前端打字机展示 picker = SkillPicker( - llm_select=_llm_select, + config=config, + model_name=keyword_model, emit_step=_emit_skill_step, max_select=5, user_query=user_query, 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 9095b94b..98c0b1c2 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 @@ -5,10 +5,11 @@ 流程(对应素材确认卡上的"技能检索"按钮): 1) list_skills 事件 —— 列出当前启用的全部 skill -2) select_skills 事件 —— LLM 根据用户输入 + 写作意图按相关度选出若干个 +2) skill_select_chunk 事件 —— LLM 流式打字 + 分离(前端打字机展示) +3) select_skills 事件 —— LLM 给出的排序结果(结构化字段,写到 SkillStepsCard 里) 下游 researcher 节点拿到 selected_skills 后,**按顺序**: -- 用 skill.description + user_query 生成针对性关键词 +- 用 skill.description + user_query 生成针对性 query - 调真实 web search provider 执行 - 累积素材达到阈值就停(推 stop_early) """ @@ -17,16 +18,23 @@ from __future__ import annotations import logging from typing import Callable, List, Optional +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.runnables import RunnableConfig + +from ..nodes._utils import ( + ThinkTagParser, + _content_to_text, + get_model, + parse_json, + safe_get_stream_writer, +) + logger = logging.getLogger(__name__) _SELECT_SKILLS_SYSTEM = """你是一名情报研究员,需要从可用「技能」中挑出对当前检索任务最有帮助的若干个,并按相关度从高到低排序。 技能是一段封装好的指令 / 知识 / 检索策略,每个技能有 name 和 description。 -【严禁思考】 -- 这是一个简单的匹配任务,禁止任何思考、分析、推理过程,禁止输出 标签。 -- 收到输入后立即直接输出最终 JSON,不得有任何前置文字。 - 【输出要求】 - 只输出一个 JSON 对象,不要 markdown 代码块。 - 必须使用英文双引号。 @@ -45,33 +53,34 @@ _SELECT_SKILLS_USER = """写作意图:{user_intent} 请从中按相关度从高到低选出最多 {max_select} 个技能。 只输出 JSON: -{{"selected_skills": ["name1", "name2"], "reason": "为什么选这些 + 排序依据"}} - -/no_think""" +{{"selected_skills": ["name1", "name2"], "reason": "为什么选这些 + 排序依据"}}""" EmitStepFn = Callable[[dict], None] class SkillPicker: - """从启用的 Skills 中选择按相关度排序的若干个供下游编排使用。 + """从启用的 Skills 中流式选择按相关度排序的若干个供下游编排使用。 构造参数: - llm_select: 异步 LLM 调用 (system, user, fallback) -> dict。 - emit_step: 可选;推送时间线步骤事件(list_skills / select_skills)。 - max_select: LLM 单次最多挑几个技能。 - user_query: 用户在素材确认卡输入框写的自然语言(可能包含技能名 / 来源 / 补充意图)。 + config: LangGraph runnable config(用于创建 model) + model_name: 指定使用的模型,None 则用 configurable.model_name + emit_step: 推送时间线步骤事件(list_skills / select_skills) + max_select: LLM 单次最多挑几个技能 + user_query: 用户在素材确认卡输入框写的自然语言 """ def __init__( self, - llm_select: Callable, + config: RunnableConfig, *, + model_name: Optional[str] = None, emit_step: Optional[EmitStepFn] = None, max_select: int = 5, user_query: str = "", ) -> None: - self._llm_select = llm_select + self._config = config + self._model_name = model_name self._emit_step = emit_step or (lambda _step: None) self._max_select = max_select self._user_query = (user_query or "").strip() @@ -98,7 +107,11 @@ class SkillPicker: return skills async def pick(self, user_intent: str) -> List[dict]: - """选择并排序候选技能,返回 [{skill, reason}, ...],列表中的 skill 按相关度降序。""" + """流式选择并排序候选技能,返回 [{skill, reason}, ...],按相关度降序。 + + 过程中通过 custom 通道推送 ``skill_select_chunk`` 事件给前端打字机; + 结束后再推一条 ``select_skills`` 结构化事件。 + """ skills = self._load_enabled_skills() if not skills: self._emit_step({ @@ -111,22 +124,46 @@ class SkillPicker: skill_list_text = "\n".join( f"- {s.name}: {s.description or '(无描述)'}" for s in skills ) + + fallback = {"selected_skills": [s.name for s in skills[: self._max_select]], "reason": ""} + + # 流式 LLM 调用:与 intent_parser / writer_outline 同套机制 + writer = safe_get_stream_writer() + parser = ThinkTagParser() + full_text = "" + + def _emit_chunk(text: str, is_thinking: bool) -> None: + writer({ + "type": "skill_select_chunk", + "text": text, + "is_thinking": is_thinking, + "agent_name": "素材收集专家", + }) + try: - result = await self._llm_select( - _SELECT_SKILLS_SYSTEM.format(max_select=self._max_select), - _SELECT_SKILLS_USER.format( + model = get_model(self._config, model_name=self._model_name) + async for chunk in model.astream([ + SystemMessage(content=_SELECT_SKILLS_SYSTEM.format(max_select=self._max_select)), + HumanMessage(content=_SELECT_SKILLS_USER.format( user_intent=user_intent or "(无明确写作意图)", user_query=self._user_query or "(无)", 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]]}, - ) + )), + ]): + delta = _content_to_text(chunk.content) + if not delta: + continue + for is_thinking, text in parser.feed(delta): + if not is_thinking: + full_text += text + _emit_chunk(text, is_thinking) except Exception as e: - logger.exception("ai_writing.skill_search: LLM 选择技能失败") + logger.exception("ai_writing.skill_search: 流式选择技能失败") self._errors.append(f"skill select error: {e}") - result = {"selected_skills": [s.name for s in skills[: self._max_select]]} + + result = parse_json(full_text, fallback) if full_text else fallback wanted_names = result.get("selected_skills") or [] if not isinstance(wanted_names, list):