意图理解

This commit is contained in:
董宏凯 committed 2026-05-21 18:48:15 +08:00
1 parent 7e98d7dc52
commit d639b24c2d
6 files changed
+292 -26

No files matched your search

@@ -40,6 +40,8 @@ import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { IntentResultCard } from './IntentResultCard';
import { IntentStreamingView } from './IntentStreamingView';
import { OutlineStreamingView } from './OutlineStreamingView';
import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
@@ -343,6 +345,17 @@ export function AIWritingPanel() {
<AIWritingForm onSubmit={startWriting} />
)}
{/* 意图解析:流式 → 结果卡片承接。
放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长,
如果把意图块塞在 timeline 下面就会随 timeline 增长被推走;
放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */}
{(state.intentStreamingText || state.intentThinkingText) && (
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
)}
{state.parsedIntent && !state.intentStreamingText && (
<IntentResultCard intent={state.parsedIntent} articleTypes={articleTypes} />
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
<AIWritingTimeline
@@ -394,7 +407,9 @@ export function AIWritingPanel() {
{showSkeleton &&
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
</div>
@@ -0,0 +1,51 @@
import { Sparkles } from 'lucide-react';
import type { ParsedIntent } from '../../types/ai-writing';
import type { ArticleType } from '../../api/ai-writing-article-types';
interface IntentResultCardProps {
intent: ParsedIntent;
articleTypes: ArticleType[];
}
/**
* 意图解析完成后的结构化卡片。展示 LLM 把用户原始意图理解成的四项:
* 文章类型 / 目标读者 / 字数 / 精炼意图。在素材收集开始前作为"承上启下"的视觉锚点。
*/
export function IntentResultCard({ intent, articleTypes }: IntentResultCardProps) {
// article_type 后端给的是 key(如 "analysis"),UI 展示用 label(如 "分析报道")
const typeLabel =
articleTypes.find((t) => t.key === intent.articleType)?.label ?? intent.articleType;
return (
<div className="px-4 pb-2">
<div className="rounded-xl border border-blue-200 dark:border-blue-900 bg-blue-50/40 dark:bg-blue-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
意图解析完成
</span>
</div>
<div className="px-3 py-2.5 space-y-1.5 text-xs">
<Row label="文章类型" value={typeLabel} />
<Row label="目标读者" value={intent.targetAudience} />
<Row label="字数目标" value={`${intent.wordCountTarget} 字`} />
{intent.refinedIntent && (
<Row label="精炼意图" value={intent.refinedIntent} multiline />
)}
</div>
</div>
</div>
);
}
function Row({ label, value, multiline }: { label: string; value: string; multiline?: boolean }) {
return (
<div className={multiline ? 'flex flex-col gap-0.5' : 'flex items-baseline gap-2'}>
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className={multiline ? 'text-foreground leading-relaxed' : 'text-foreground truncate'}>
{value}
</span>
</div>
);
}
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react';
import { Sparkles, ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface IntentStreamingViewProps {
text: string;
thinkingText?: string;
}
/**
* 意图解析流式视图:与 OutlineStreamingView / DraftStreamingView 同样的打字机机制,
* 颜色用蓝色主题区分意图理解阶段。展示 LLM 边吐 JSON 边滚动的过程,结果由
* IntentResultCard 接力展示结构化字段。
*/
export function IntentStreamingView({ text, thinkingText }: IntentStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
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-blue-200 dark:border-blue-900 bg-blue-50/50 dark:bg-blue-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-200/60 dark:border-blue-900/60 bg-blue-100/40 dark:bg-blue-900/30">
<Sparkles className="size-3.5 text-blue-500 shrink-0" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
意图解析中…
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1 rounded-full bg-blue-400 animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</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-blue-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; }
}
`}</style>
</div>
);
}
@@ -36,6 +36,7 @@ import type {
AIOutline,
BlockedSection,
CompletedIntervention,
ParsedIntent,
ProgressEvent,
ProgressEventType,
ReviewIssue,
@@ -66,6 +67,9 @@ const INITIAL_STATE: AIWritingState = {
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
intentStreamingText: '',
intentThinkingText: '',
parsedIntent: null,
researchProgress: '',
streamingReviewItems: [],
revisionSummary: null,
@@ -146,6 +150,41 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'step_done':
return base;
case 'intent_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
intentStreamingText: isThinking ? prev.intentStreamingText : prev.intentStreamingText + text,
intentThinkingText: isThinking ? prev.intentThinkingText + text : prev.intentThinkingText,
};
}
case 'intent_ready': {
const ev = event as unknown as {
articleType?: string;
article_type?: string;
targetAudience?: string;
target_audience?: string;
wordCountTarget?: number;
word_count_target?: number;
refinedIntent?: string;
refined_intent?: string;
};
const parsed: ParsedIntent = {
articleType: ev.articleType ?? ev.article_type ?? '',
targetAudience: ev.targetAudience ?? ev.target_audience ?? '',
wordCountTarget: Number(ev.wordCountTarget ?? ev.word_count_target ?? 800),
refinedIntent: ev.refinedIntent ?? ev.refined_intent ?? '',
};
return {
...base,
parsedIntent: parsed,
intentStreamingText: '',
intentThinkingText: '',
};
}
case 'research_progress':
return { ...base, researchProgress: event.message ?? '' };
@@ -403,6 +442,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
// 后端 _save_draft_to_repo 等 helper 不走 custom 通道,type 必为节点 emit 的 chunk 类型
if (!type) return null;
const allowed: ProgressEventType[] = [
'intent_chunk', 'intent_ready',
'research_progress', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done',
];
@@ -84,6 +84,8 @@ export type ProgressEventType =
| 'connected'
| 'step_start'
| 'step_done'
| 'intent_chunk'
| 'intent_ready'
| 'research_progress'
| 'materials_ready'
| 'outline_chunk'
@@ -213,6 +215,14 @@ export interface CompletedIntervention {
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
/** 意图解析最终结构化字段,来自 intent_ready 事件。 */
export interface ParsedIntent {
articleType: string;
targetAudience: string;
wordCountTarget: number;
refinedIntent: string;
}
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
@@ -234,6 +244,10 @@ export interface AIWritingState {
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 意图解析(流式 + 结果)
intentStreamingText: string;
intentThinkingText: string;
parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 流式审核(逐条到达)