提交代码

This commit is contained in:
董宏凯 committed 2026-07-01 19:24:23 +08:00
1 parent d21dbeb214
commit 54190d7a83
8 files changed
+1205 -11

No files matched your search

@@ -39,6 +39,7 @@ import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { SkillStepsCard } from './SkillStepsCard';
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
import { KeywordStreamingView } from './KeywordStreamingView';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftGeneratingView } from './DraftGeneratingView';
import { DraftSync } from './DraftSync';
@@ -283,6 +284,8 @@ export function AIWritingPanel({
state.intentStreamingText,
state.intentThinkingText,
state.parsedIntent,
state.keywordStreamingText,
state.keywordThinkingText,
state.skillSearchSteps.length,
state.skillSelectStreamingText,
state.skillSelectThinkingText,
@@ -642,6 +645,16 @@ export function AIWritingPanel({
<DraftGeneratingView sections={state.streamingDraftSections} />
)}
{/* 检索词生成过程:先展示模型如何从写作意图提炼搜索方向,再进入技能匹配 / 检索。 */}
{state.status === 'researching' && (
state.keywordStreamingText || state.keywordThinkingText
) && (
<KeywordStreamingView
text={state.keywordStreamingText}
thinkingText={state.keywordThinkingText}
/>
)}
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
挂载条件包含"过渡态":已经走到 match_skills 但还没出 select_skills 时也保持挂载,
让首个空 chunk 一到就能立刻显示等待提示 + 闪烁光标,避免模型 buffer 期间静默。 */}
@@ -675,11 +688,13 @@ export function AIWritingPanel({
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 技能检索占位卡 / 选择打字机 / 已流式的技能步骤 已经在展示,不再叠加 skeleton。
/* 检索词生成卡 / 技能检索占位卡 / 选择打字机 / 已流式的技能步骤
已经在展示,不再叠加 skeleton。
(研究阶段一旦有 skill_search_step,就改由上面的 SkillStepsCard 实时呈现进度,
研究词生成阶段 steps 还为空时仍走 skeleton 透传 researchProgress 文案。) */
关键词 chunk 还没到之前仍走 skeleton 透传 researchProgress 文案。) */
!state.skillSearchPending &&
!(state.status === 'researching' && state.skillSearchSteps.length > 0) &&
!(state.status === 'researching' && (state.keywordStreamingText || state.keywordThinkingText)) &&
!(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
/* 样文分析已有流式过程卡时,不再叠加 skeleton */
!state.sampleAnalysisStreamingText && !state.sampleAnalysisThinkingText &&
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight, Hash, Search } from 'lucide-react';
interface KeywordStreamingViewProps {
text: string;
thinkingText?: string;
}
function extractStreamingKeywords(text: string): string[] {
if (!text) return [];
const keyMatch = text.match(/["']?keywords["']?\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 keywords: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(arrBody)) !== null) {
const keyword = m[1]
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
.trim();
if (keyword) keywords.push(keyword);
}
return Array.from(new Set(keywords));
}
export function KeywordStreamingView({ text, thinkingText }: KeywordStreamingViewProps) {
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
const keywords = useMemo(() => extractStreamingKeywords(text), [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}, [thinkingText, thinkOpen]);
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-[keyword-blink_1s_step-end_infinite]" />
)}
</pre>
)}
</div>
)}
<div className="rounded-xl border border-sky-200 dark:border-sky-900 bg-sky-50/60 dark:bg-sky-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-200/70 dark:border-sky-900/60 bg-sky-100/45 dark:bg-sky-900/30">
<Search className="size-3.5 text-sky-500 shrink-0" />
<span className="text-xs font-medium text-sky-700 dark:text-sky-300">
生成检索词
{keywords.length > 0 && (
<span className="ml-1 text-sky-600/80 dark:text-sky-300/80 font-normal">
· 已识别 {keywords.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-sky-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
<div className="px-3 py-2.5">
{keywords.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{keywords.map((keyword, idx) => (
<span
key={`${keyword}-${idx}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] bg-sky-100 dark:bg-sky-900/60 text-sky-700 dark:text-sky-200 animate-[keyword-fade-in_0.25s_ease-out]"
style={{ animationFillMode: 'both' }}
>
<Hash className="size-2.5 shrink-0" />
{keyword}
</span>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground italic flex items-center gap-1">
等待模型提炼检索方向
<span className="inline-block w-0.5 h-3 bg-sky-400 align-text-bottom animate-[keyword-blink_1s_step-end_infinite]" />
</div>
)}
</div>
</div>
<style>{`
@keyframes keyword-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes keyword-fade-in {
from { opacity: 0; transform: translateY(-2px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
);
}
@@ -52,6 +52,7 @@ const BATCHED_CHUNK_TYPES = new Set<ProgressEvent['type']>([
'draft_chunk',
'outline_chunk',
'intent_chunk',
'keyword_chunk',
'skill_select_chunk',
'review_chunk',
]);
@@ -66,6 +66,8 @@ const INITIAL_STATE: AIWritingState = {
intentThinkingText: '',
parsedIntent: null,
researchProgress: '',
keywordStreamingText: '',
keywordThinkingText: '',
skillSearchSteps: [],
skillSelectStreamingText: '',
skillSelectThinkingText: '',
@@ -126,7 +128,14 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
currentPause: null,
status: ns,
skillSearchPending: enablingSkillSearch ? true : prev.skillSearchPending,
...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}),
...(ns === 'researching' ? {
researchProgress: '',
keywordStreamingText: '',
keywordThinkingText: '',
skillSearchSteps: [],
skillSelectStreamingText: '',
skillSelectThinkingText: '',
} : {}),
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
@@ -160,6 +169,7 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
outlineStreamingText: '', outlineThinkingText: '',
streamingDraftSections: [], streamingDraftTitle: '',
streamingReviewItems: [],
keywordStreamingText: '', keywordThinkingText: '',
skillSelectStreamingText: '', skillSelectThinkingText: '',
};
@@ -260,6 +270,18 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'research_progress':
return { ...base, researchProgress: event.message ?? '' };
case 'keyword_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const visibleText = text || (prev.keywordStreamingText ? '' : ' ');
return {
...prev,
status: 'researching',
keywordStreamingText: isThinking ? prev.keywordStreamingText : prev.keywordStreamingText + visibleText,
keywordThinkingText: isThinking ? prev.keywordThinkingText + text : prev.keywordThinkingText,
};
}
case 'sample_profile_ready':
return {
...base,
@@ -299,6 +321,8 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
keywordStreamingText: '',
keywordThinkingText: '',
...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}),
};
}
@@ -852,7 +876,7 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
'intent_chunk', 'intent_ready',
'sample_chunk',
'sample_profile_ready',
'research_progress', 'outline_chunk', 'draft_chunk',
'research_progress', 'keyword_chunk', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done',
'skill_search_step', 'skill_select_chunk',
'model_switch',
@@ -179,6 +179,7 @@ export type ProgressEventType =
| "sample_profile_ready"
| "intent_chunk"
| "intent_ready"
| "keyword_chunk"
| "research_progress"
| "materials_ready"
| "outline_chunk"
@@ -408,6 +409,9 @@ export interface AIWritingState {
parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 检索词生成 LLM 流式打字内容(正文 / 思考分开)
keywordStreamingText: string;
keywordThinkingText: string;
// 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线)
skillSearchSteps: SkillSearchStep[];
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空