提交代码
This commit is contained in:
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 完成后清空
|
||||
|
||||
Reference in new issue
Block a user