技能联调

This commit is contained in:
fengxiangdebaozi committed 2026-05-29 15:22:28 +08:00
1 parent 07ad87365f
commit a9332c0ab0
7 files changed
+369 -145

No files matched your search

+112 -111
View File
@@ -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;
}
@@ -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 流式打字(含 <think> 思考分离),select_skills 完成后自动清空 */}
{state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && (
<SkillSelectStreamingView
text={state.skillSelectStreamingText}
thinkingText={state.skillSelectThinkingText}
/>
)}
{/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */}
{state.status === 'researching' && state.skillSearchSteps.length > 0 && (
<div className="px-4 pb-2">
@@ -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 && (
<div className="px-4 pb-4">
@@ -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 流式打字机视图。
*
* - 思考过程(<think></think> 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致)
* - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义)
* - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标
*/
export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(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 (
<div className="px-4 pb-2 space-y-2">
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
type="button"
onClick={() => setThinkOpen((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
>
<Brain className="size-3.5 text-amber-500 shrink-0" />
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
思考过程
</span>
<span className="ml-auto text-amber-400">
{thinkOpen
? <ChevronDown className="size-3.5" />
: <ChevronRight className="size-3.5" />}
</span>
</button>
{thinkOpen && (
<pre
ref={thinkRef}
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
>
{thinkingText}
{!text && (
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
{text && (
<div className="rounded-xl border border-violet-200 dark:border-violet-900 bg-violet-50/50 dark:bg-violet-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-200/60 dark:border-violet-900/60 bg-violet-100/40 dark:bg-violet-900/30">
<Sparkles className="size-3.5 text-violet-500 shrink-0" />
<span className="text-xs font-medium text-violet-600 dark:text-violet-400">
正在筛选相关技能
{streamingSkills.length > 0 && (
<span className="ml-1 text-violet-500/80">
· 已识别 {streamingSkills.length} 个
</span>
)}
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-violet-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
{/* 已识别的技能名:每个新出现的 badge 会 fade-in,给用户即时反馈 */}
{streamingSkills.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-3 py-2 border-b border-violet-200/40 dark:border-violet-900/40 bg-violet-50/40 dark:bg-violet-950/10">
{streamingSkills.map((name, idx) => (
<span
key={`${name}-${idx}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
style={{ animationFillMode: 'both' }}
>
<Zap className="size-2.5 shrink-0" />
{name}
</span>
))}
</div>
)}
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
>
{text}
<span className="inline-block w-0.5 h-3.5 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
</div>
)}
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes skill-fade-in {
from { opacity: 0; transform: translateY(-2px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
);
}
@@ -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;
@@ -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[];
// 修改摘要
@@ -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)+ <think> 分离,前端打字机展示
picker = SkillPicker(
llm_select=_llm_select,
config=config,
model_name=keyword_model,
emit_step=_emit_skill_step,
max_select=5,
user_query=user_query,
@@ -5,10 +5,11 @@
流程(对应素材确认卡上的"技能检索"按钮):
1) list_skills 事件 —— 列出当前启用的全部 skill
2) select_skills 事件 —— LLM 根据用户输入 + 写作意图按相关度选出若干个
2) skill_select_chunk 事件 —— LLM 流式打字 + <think></think> 分离(前端打字机展示)
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。
【严禁思考】
- 这是一个简单的匹配任务,禁止任何思考、分析、推理过程,禁止输出 <think></think> 标签。
- 收到输入后立即直接输出最终 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):