技能联调
This commit is contained in:
1 parent
07ad87365f
commit
a9332c0ab0
7 files changed
+369
-145
No files matched your search
@@ -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[];
|
||||
// 修改摘要
|
||||
|
||||
+3
-4
@@ -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,
|
||||
|
||||
+62
-25
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user