意图理解
This commit is contained in:
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;
|
||||
// 流式审核(逐条到达)
|
||||
|
||||
+70
-25
@@ -6,8 +6,16 @@ import re
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from ..state import AIWritingState
|
||||
from ._utils import llm_json
|
||||
from ._utils import (
|
||||
ThinkTagParser,
|
||||
_content_to_text,
|
||||
get_model,
|
||||
parse_json,
|
||||
safe_get_stream_writer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -138,42 +146,79 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
if fallback_type not in valid_keys:
|
||||
fallback_type = default_key
|
||||
|
||||
result = await llm_json(
|
||||
system,
|
||||
user,
|
||||
fallback={
|
||||
"article_type": fallback_type,
|
||||
"target_audience": state.get("target_audience", "普通读者"),
|
||||
"word_count_target": state.get("word_count_target", 800),
|
||||
"refined_intent": state["user_intent"],
|
||||
},
|
||||
config=config,
|
||||
)
|
||||
fallback_result = {
|
||||
"article_type": fallback_type,
|
||||
"target_audience": state.get("target_audience", "普通读者"),
|
||||
"word_count_target": state.get("word_count_target", 800),
|
||||
"refined_intent": state["user_intent"],
|
||||
}
|
||||
|
||||
# 意图解析模型调用失败时以非致命 notice 提示用户(llm_json 已用 fallback 兜底,
|
||||
# 流程仍可继续,但用户需知道结构化配置可能不准)。
|
||||
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
|
||||
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
|
||||
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
|
||||
writer = safe_get_stream_writer()
|
||||
llm_error: str | None = None
|
||||
full_text = ""
|
||||
parser = ThinkTagParser()
|
||||
try:
|
||||
model = get_model(config)
|
||||
async for chunk in model.astream(
|
||||
[SystemMessage(content=system), HumanMessage(content=user)]
|
||||
):
|
||||
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
|
||||
writer({
|
||||
"type": "intent_chunk",
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
})
|
||||
except Exception as exc:
|
||||
# 模型 / 网络挂掉 —— 不阻断流程,用 fallback 继续。
|
||||
logger.error("intent_parser streaming failed: %s", exc, exc_info=True)
|
||||
llm_error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
result = parse_json(full_text, fallback_result) if full_text else fallback_result
|
||||
|
||||
# 意图解析模型调用失败时以非致命 notice 提示用户(fallback 已兜底,流程仍可继续)
|
||||
progress_events: list[dict] = []
|
||||
if result.get("_llm_error"):
|
||||
if llm_error:
|
||||
progress_events.append({
|
||||
"type": "notice",
|
||||
"agent_name": "系统",
|
||||
"title": "模型错误",
|
||||
"detail": f"意图解析失败,已按默认配置兜底。原因:{result['_llm_error']}",
|
||||
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
|
||||
})
|
||||
progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"})
|
||||
|
||||
updates: dict = {"status": "researching", "progress_events": progress_events}
|
||||
|
||||
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
|
||||
raw_type = result.get("article_type", "")
|
||||
updates["article_type"] = raw_type if raw_type in valid_keys else fallback_type
|
||||
|
||||
if result.get("target_audience"):
|
||||
updates["target_audience"] = result["target_audience"]
|
||||
final_article_type = raw_type if raw_type in valid_keys else fallback_type
|
||||
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
|
||||
word_count = _coerce_word_count(result.get("word_count_target"))
|
||||
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
|
||||
final_refined_intent = result.get("refined_intent") or state["user_intent"]
|
||||
|
||||
# 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图
|
||||
writer({
|
||||
"type": "intent_ready",
|
||||
"article_type": final_article_type,
|
||||
"target_audience": final_target_audience,
|
||||
"word_count_target": final_word_count,
|
||||
"refined_intent": final_refined_intent,
|
||||
})
|
||||
|
||||
progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"})
|
||||
|
||||
updates: dict = {"status": "researching", "progress_events": progress_events}
|
||||
updates["article_type"] = final_article_type
|
||||
if result.get("target_audience"):
|
||||
updates["target_audience"] = final_target_audience
|
||||
if word_count is not None:
|
||||
updates["word_count_target"] = word_count
|
||||
updates["word_count_target"] = final_word_count
|
||||
if result.get("refined_intent"):
|
||||
updates["user_intent"] = result["refined_intent"]
|
||||
updates["user_intent"] = final_refined_intent
|
||||
|
||||
return updates
|
||||
Reference in new issue
Block a user