fix: improve ai writing chat streaming and intent handling

This commit is contained in:
董宏凯 committed 2026-07-02 17:43:23 +08:00
1 parent 845c87e3ee
commit da856f2b58
5 files changed
+1633 -158

No files matched your search

@@ -4,8 +4,9 @@
* 两级解析:
* 1. `fastPathIntent`:高频确定性短句的前端规则快路径(确认/继续/重新搜索/重写大纲…),
* 零延迟、零误判;**高代价动作(定稿/强制定稿/覆盖式重搜的模糊说法)绝不走快路径**。
* 2. `parseWritingIntent`:POST /api/ai-writing/sessions/{id}/intent —— 后端读该会话的
* graph checkpoint(素材/大纲/草稿/审核摘要)+ LLM 解析,返回结构化意图契约。
* 2. `streamWritingIntent`:POST /api/ai-writing/sessions/{id}/intent/stream —— 后端读该会话的
* graph checkpoint(素材/大纲/草稿/审核摘要)+ LLM 流式解析,实时返回 thinking/text,
* 最后一帧返回结构化意图契约。`parseWritingIntent` 保留为一次性兼容接口。
*
* 返回的 payload 字段与 `UserInterventionPayload` 对齐,可直接喂 `submitIntervention`。
*/
@@ -48,11 +49,19 @@ const TO_EDITOR_RE = /^((交给|让)?编辑(继续)?审核|送审|提交(编辑)
const ACCEPT_REVIEW_RE = /^(按编辑(的)?意见(修改|改)|采纳编辑意见)$/;
const SECTION_ALL_SUPPLEMENT_RE = /^(都|全部|全都)(补充|补)检索(素材)?$/;
const SECTION_ALL_LOOSE_RE = /^(都|全部|全都)(按通用知识|用通用知识)?(续写|写|继续写)$/;
const WRITING_ACTION_VERB_RE = /(新增|增加|添加|加入|加上|补充|补上|补个|补一个|生成|撰写|写一|写个|写成|扩写|缩写|精简|改写|润色|调整|修改|重写|删除|删掉|拆成|合并|挪到|移动|规划|重新规划|设计|安排)/;
const WRITING_ACTION_TARGET_RE = /(第[一二三四五六七八九十\d]+[章节段]|章节|章|小节|段落|段|结尾|开头|标题|大纲|内容|案例|表格|总结章|总结章节|结论|摘要|观点|部分)/;
const INFO_QUESTION_RE = /(哪些|什么|为什么|是什么|有哪些|多少|是否)/;
function stripPunct(text: string): string {
return text.trim().replace(/[。..!!~~\s]+$/g, '');
}
function looksLikeWritingChangeRequest(text: string): boolean {
if (INFO_QUESTION_RE.test(text)) return false;
return WRITING_ACTION_VERB_RE.test(text) && WRITING_ACTION_TARGET_RE.test(text);
}
function ruleResult(
intent: string,
pausePoint: string | null,
@@ -88,7 +97,7 @@ export function fastPathIntent(
},
): WritingIntentResult | null {
const text = stripPunct(rawText);
if (!text || text.length > 12) return null;
if (!text) return null;
// 「暂停中 + 继续」= 恢复运行(控制类,优先于阶段确认语义)
if (ctx.isPaused && CONFIRM_RE.test(text)) {
@@ -98,6 +107,29 @@ export function fastPathIntent(
const pp = ctx.pausePoint;
if (!pp) return null;
if (pp === 'outline_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('revise_outline', pp, 're_outline', {
action: 're_outline',
outlineFeedback: rawText.trim(),
});
}
if (pp === 'draft_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('revise_draft', pp, 'user_revise', {
action: 'user_revise',
userRevisionNotes: rawText.trim(),
});
}
if (pp === 'review_confirm' && looksLikeWritingChangeRequest(text)) {
return ruleResult('accept_review_with_user_notes', pp, 'accept_review', {
action: 'accept_review',
userRevisionNotes: rawText.trim(),
});
}
if (text.length > 12) return null;
if (pp === 'material_confirm') {
if (CONFIRM_RE.test(text)) {
// 不带 approvedMaterialIds → 后端默认采用全部素材
@@ -169,6 +201,25 @@ interface RawIntentResponse {
clarify?: string | null;
}
export type WritingIntentStreamEvent =
| { type: 'thinking'; chunk: string }
| { type: 'text'; chunk: string }
| { type: 'result'; result: WritingIntentResult }
| { type: 'done' }
| { type: 'error'; message: string };
export type WritingAnswerStreamEvent =
| { type: 'start' }
| { type: 'thinking'; chunk: string }
| { type: 'text'; chunk: string }
| { type: 'done' }
| { type: 'error'; message: string };
export interface WritingSuggestionMessage {
role: 'user' | 'assistant';
content: string;
}
function normalizePayload(raw: Record<string, unknown> | null | undefined, action: string | null): UserInterventionPayload | null {
if (!action) return null;
const p = raw ?? {};
@@ -196,6 +247,54 @@ function normalizePayload(raw: Record<string, unknown> | null | undefined, actio
return payload;
}
function normalizeIntentResponse(raw: RawIntentResponse): WritingIntentResult {
const action = raw.action || null;
return {
intent: raw.intent || 'unknown',
pausePoint: raw.pause_point || null,
action,
payload: normalizePayload(raw.payload, action),
needResearch: Boolean(raw.need_research),
researchQuery: (raw.research_query || '').trim(),
answer: (raw.answer || '').trim(),
confidence: typeof raw.confidence === 'number' ? Math.max(0, Math.min(1, raw.confidence)) : 0,
clarify: (raw.clarify || '').trim(),
source: 'llm',
};
}
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trimEnd();
if (trimmed.startsWith('event: ')) {
currentEvent = trimmed.slice(7);
} else if (trimmed.startsWith('data: ')) {
yield { event: currentEvent, data: trimmed.slice(6) };
currentEvent = '';
} else if (trimmed === '') {
currentEvent = '';
}
}
}
} finally {
reader.releaseLock();
}
}
/** 后端 LLM 意图解析。会话必须已存在(sessionId = graph thread_id)。 */
export async function parseWritingIntent(
sessionId: string,
@@ -218,17 +317,129 @@ export async function parseWritingIntent(
throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
const raw = (await resp.json()) as RawIntentResponse;
const action = raw.action || null;
return {
intent: raw.intent || 'unknown',
pausePoint: raw.pause_point || null,
action,
payload: normalizePayload(raw.payload, action),
needResearch: Boolean(raw.need_research),
researchQuery: (raw.research_query || '').trim(),
answer: (raw.answer || '').trim(),
confidence: typeof raw.confidence === 'number' ? Math.max(0, Math.min(1, raw.confidence)) : 0,
clarify: (raw.clarify || '').trim(),
source: 'llm',
};
return normalizeIntentResponse(raw);
}
/** 后端 LLM 意图解析的 SSE 流式版本,包含 `<think>`/reasoning 思考增量。 */
export async function* streamWritingIntent(
sessionId: string,
body: { text: string; status?: string; pausePoint?: string | null },
signal?: AbortSignal,
): AsyncGenerator<WritingIntentStreamEvent> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/intent/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: body.text,
status: body.status ?? null,
pause_point: body.pausePoint ?? null,
}),
signal,
},
);
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`意图解析失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
if (!resp.body) {
throw new Error('意图解析失败:响应体为空');
}
for await (const { event, data } of parseSse(resp.body)) {
if (signal?.aborted) return;
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
if (event === 'thinking') {
yield { type: 'thinking', chunk: String(parsed.chunk ?? '') };
} else if (event === 'text') {
yield { type: 'text', chunk: String(parsed.chunk ?? '') };
} else if (event === 'result') {
yield { type: 'result', result: normalizeIntentResponse(parsed as RawIntentResponse) };
} else if (event === 'done') {
yield { type: 'done' };
return;
} else if (event === 'error') {
yield { type: 'error', message: String(parsed.message ?? '意图解析流式接口异常') };
return;
}
} catch {
// 忽略畸形帧,等待下一帧或 done。
}
}
}
export async function fetchWritingAnswerSuggestions(
sessionId: string,
body: { messages: WritingSuggestionMessage[]; n?: number; modelName?: string | null },
): Promise<string[]> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/suggestions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: body.messages,
n: body.n ?? 3,
model_name: body.modelName || null,
}),
},
);
if (!resp.ok) return [];
const raw = (await resp.json().catch(() => ({}))) as { suggestions?: unknown[] };
return (raw.suggestions ?? [])
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, body.n ?? 3);
}
export async function* streamWritingAnswer(
sessionId: string,
body: { text: string; status?: string; pausePoint?: string | null; modelName?: string | null },
signal?: AbortSignal,
): AsyncGenerator<WritingAnswerStreamEvent> {
const resp = await apiFetch(
`${getBackendBaseURL()}/api/ai-writing/sessions/${encodeURIComponent(sessionId)}/answer/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: body.text,
status: body.status ?? null,
pause_point: body.pausePoint ?? null,
model_name: body.modelName || null,
}),
signal,
},
);
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
throw new Error(`回答生成失败 ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
if (!resp.body) {
throw new Error('回答生成失败:响应体为空');
}
for await (const { event, data } of parseSse(resp.body)) {
if (signal?.aborted) return;
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
if (event === 'start') {
yield { type: 'start' };
} else if (event === 'thinking') {
yield { type: 'thinking', chunk: String(parsed.chunk ?? '') };
} else if (event === 'text') {
yield { type: 'text', chunk: String(parsed.chunk ?? '') };
} else if (event === 'done') {
yield { type: 'done' };
return;
} else if (event === 'error') {
yield { type: 'error', message: String(parsed.message ?? '回答生成失败') };
return;
}
} catch {
// 忽略畸形帧,等待下一帧或 done。
}
}
}
@@ -7,29 +7,58 @@
* 时间流 —— 用户发消息后,由它触发的检索/操作事件出现在消息**下方**(而不是
* 旧版「时间线在上、对话在下」两块分离导致的顺序错乱)。
*
* 时序对齐方式:每条对话消息记录创建时的 `progressEvents.length` 作为锚点
* (anchor),渲染时消息插在「前 anchor 条事件之后、第 anchor+1 条事件之前」。
* 时序对齐方式:每条对话消息记录创建时的信息流长度作为锚点(流程事件 +
* 已经出现的稳定尾部槽位),渲染时消息插在对应位置之后。
*/
import type { ReactNode } from 'react';
import { BadgeCheck, Bot, CircleHelp, Info } from 'lucide-react';
import { useMemo, useState, type ReactNode } from 'react';
import { BadgeCheck, Bot, Check, CircleHelp, Info, Send, Sparkles, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { resolveAllowMultiple } from '@/roundtable-planning/lib/clarification';
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from '../ai-writing/TimelineStep';
import { CompletedInterventionCard } from '../ai-writing/CompletedInterventionCard';
import { UserRequirementCard } from '../ai-writing/UserRequirementCard';
export type ChatKind = 'text' | 'clarify' | 'answer' | 'ack' | 'notice';
export type ChatKind = 'text' | 'clarify' | 'answer' | 'ack' | 'notice' | 'assist';
export interface ChatClarificationOption {
id: string;
label: string;
description?: string;
}
export interface ChatClarificationPayload {
question?: string;
context?: string | null;
clarification_type?: string;
options?: ChatClarificationOption[];
allow_custom?: boolean;
allow_multiple?: boolean;
}
export interface ChatFeedMessage {
id: string;
role: 'user' | 'assistant';
kind: ChatKind;
text: string;
/** 创建该消息时 progressEvents 的长度:消息渲染在前 anchor 条事件之后。 */
clarification?: ChatClarificationPayload;
answered?: boolean;
suggestions?: string[];
suggestionsLoading?: boolean;
suggestionsHidden?: boolean;
streaming?: boolean;
/** 创建该消息时的信息流长度:消息渲染在前 anchor 个流程单元之后。 */
anchor: number;
}
interface ClarificationSelection {
selectedIds: string[];
custom: string;
}
const DISPLAYED_TYPES = new Set([
'step_start',
'step_done',
@@ -48,6 +77,7 @@ const DISPLAYED_TYPES = new Set([
type FeedRow =
| { key: string; type: 'event'; event: ProgressEvent; slot: ReactNode; extendLineThroughSlot: boolean }
| { key: string; type: 'slot'; slot: ReactNode }
| { key: string; type: 'chat'; msg: ChatFeedMessage };
interface ChatFlowTimelineProps {
@@ -55,10 +85,17 @@ interface ChatFlowTimelineProps {
completedInterventions: CompletedIntervention[];
currentPause: ProgressEvent | null;
currentInterventionSlot?: ReactNode;
/** 渲染在流程事件之后的稳定尾部槽位;后续消息会锚到它下面。 */
revisionSummarySlot?: ReactNode;
chatMessages: ChatFeedMessage[];
/** 由统一输入框触发的完成卡锚点:完成卡跟在对应用户/助手气泡后面。 */
completedInterventionAnchors?: Map<number, number>;
/** 由底部输入框发起的干预序号集合:这些干预的「我的要求」卡不再重复渲染
* (用户气泡本身已按时间顺序展示了原话)。 */
suppressedRequirementIndices?: Set<number>;
onClarificationSubmit?: (messageId: string, answer: string) => void;
onSuggestionClick?: (suggestion: string) => void;
onSuggestionsDismiss?: (messageId: string) => void;
}
export function ChatFlowTimeline({
@@ -66,19 +103,40 @@ export function ChatFlowTimeline({
completedInterventions,
currentPause,
currentInterventionSlot,
revisionSummarySlot,
chatMessages,
completedInterventionAnchors,
suppressedRequirementIndices,
onClarificationSubmit,
onSuggestionClick,
onSuggestionsDismiss,
}: ChatFlowTimelineProps) {
if (events.length === 0 && chatMessages.length === 0) return null;
// 消息按锚点分桶(锚点超出当前事件数的挂在末尾)
const tailSlotCount = revisionSummarySlot ? 1 : 0;
const maxAnchor = events.length + tailSlotCount;
// 消息按锚点分桶(锚点超出当前信息流长度的挂在末尾)
const msgsByAnchor = new Map<number, ChatFeedMessage[]>();
for (const msg of chatMessages) {
const anchor = Math.max(0, Math.min(msg.anchor, events.length));
const anchor = Math.max(0, Math.min(msg.anchor, maxAnchor));
const bucket = msgsByAnchor.get(anchor);
if (bucket) bucket.push(msg);
else msgsByAnchor.set(anchor, [msg]);
}
const completionRowsByAnchor = new Map<number, FeedRow[]>();
completedInterventionAnchors?.forEach((rawAnchor, interventionIdx) => {
const record = completedInterventions[interventionIdx];
if (!record) return;
const anchor = Math.max(0, Math.min(rawAnchor, maxAnchor));
const rows = completionRowsByAnchor.get(anchor) ?? [];
rows.push({
key: `composer-completed-${interventionIdx}`,
type: 'slot',
slot: <CompletedInterventionCard record={record} />,
});
completionRowsByAnchor.set(anchor, rows);
});
// 最后一个 await_user 事件的全量下标:只有它才可能挂当前干预卡
let lastAwaitUserIdx = -1;
@@ -87,15 +145,18 @@ export function ChatFlowTimeline({
});
const rows: FeedRow[] = [];
const pushChats = (anchor: number) => {
const pushAnchoredRows = (anchor: number) => {
for (const msg of msgsByAnchor.get(anchor) ?? []) {
rows.push({ key: msg.id, type: 'chat', msg });
}
for (const completionRow of completionRowsByAnchor.get(anchor) ?? []) {
rows.push(completionRow);
}
};
let awaitUserIdx = 0;
events.forEach((event, fullIdx) => {
pushChats(fullIdx);
pushAnchoredRows(fullIdx);
if (!DISPLAYED_TYPES.has(event.type)) return;
let slot: ReactNode = null;
@@ -107,13 +168,16 @@ export function ChatFlowTimeline({
awaitUserIdx += 1;
if (completedRecord) {
const showRequirement = !suppressedRequirementIndices?.has(interventionIdx);
slot = (
<div className="space-y-2">
{showRequirement && <UserRequirementCard record={completedRecord} />}
<CompletedInterventionCard record={completedRecord} />
</div>
);
const movedToComposerAnchor = completedInterventionAnchors?.has(interventionIdx);
if (!movedToComposerAnchor) {
const showRequirement = !suppressedRequirementIndices?.has(interventionIdx);
slot = (
<div className="space-y-2">
{showRequirement && <UserRequirementCard record={completedRecord} />}
<CompletedInterventionCard record={completedRecord} />
</div>
);
}
} else if (currentPause && fullIdx === lastAwaitUserIdx) {
slot = currentInterventionSlot;
extendLineThroughSlot = false;
@@ -122,14 +186,32 @@ export function ChatFlowTimeline({
rows.push({ key: `event-${fullIdx}`, type: 'event', event, slot, extendLineThroughSlot });
});
pushChats(events.length);
if (revisionSummarySlot) {
pushAnchoredRows(events.length);
rows.push({ key: 'revision-summary', type: 'slot', slot: revisionSummarySlot });
pushAnchoredRows(events.length + 1);
} else {
pushAnchoredRows(events.length);
}
return (
<div className="px-4 py-3 space-y-0">
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
if (row.type === 'chat') {
return <ChatFeedRow key={row.key} msg={row.msg} isLast={isLast} />;
return (
<ChatFeedRow
key={row.key}
msg={row.msg}
isLast={isLast}
onClarificationSubmit={onClarificationSubmit}
onSuggestionClick={onSuggestionClick}
onSuggestionsDismiss={onSuggestionsDismiss}
/>
);
}
if (row.type === 'slot') {
return <TimelineAuxRow key={row.key} isLast={isLast}>{row.slot}</TimelineAuxRow>;
}
return (
<TimelineStep
@@ -145,8 +227,33 @@ export function ChatFlowTimeline({
);
}
function TimelineAuxRow({ children, isLast }: { children: ReactNode; isLast: boolean }) {
return (
<div className="flex gap-3">
<div className="flex w-4 shrink-0 flex-col items-center">
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className={cn('min-w-0 flex-1 pb-3', isLast && 'pb-0')}>
{children}
</div>
</div>
);
}
/** 信息流里的对话气泡行:左侧延续时间线竖线,与流程事件保持同一视觉脊柱。 */
function ChatFeedRow({ msg, isLast }: { msg: ChatFeedMessage; isLast: boolean }) {
function ChatFeedRow({
msg,
isLast,
onClarificationSubmit,
onSuggestionClick,
onSuggestionsDismiss,
}: {
msg: ChatFeedMessage;
isLast: boolean;
onClarificationSubmit?: (messageId: string, answer: string) => void;
onSuggestionClick?: (suggestion: string) => void;
onSuggestionsDismiss?: (messageId: string) => void;
}) {
return (
<div className="flex gap-3">
{/* 竖线占位列(与 TimelineStep 的 size-4 图标列同宽),保持时间线连续 */}
@@ -154,13 +261,28 @@ function ChatFeedRow({ msg, isLast }: { msg: ChatFeedMessage; isLast: boolean })
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className={cn('min-w-0 flex-1 pb-3', isLast && 'pb-0')}>
<ChatBubble msg={msg} />
<ChatBubble
msg={msg}
onClarificationSubmit={onClarificationSubmit}
onSuggestionClick={onSuggestionClick}
onSuggestionsDismiss={onSuggestionsDismiss}
/>
</div>
</div>
);
}
export function ChatBubble({ msg }: { msg: Pick<ChatFeedMessage, 'role' | 'kind' | 'text'> }) {
export function ChatBubble({
msg,
onClarificationSubmit,
onSuggestionClick,
onSuggestionsDismiss,
}: {
msg: ChatFeedMessage;
onClarificationSubmit?: (messageId: string, answer: string) => void;
onSuggestionClick?: (suggestion: string) => void;
onSuggestionsDismiss?: (messageId: string) => void;
}) {
if (msg.role === 'user') {
return (
<div className="flex justify-end">
@@ -175,6 +297,7 @@ export function ChatBubble({ msg }: { msg: Pick<ChatFeedMessage, 'role' | 'kind'
text: <Bot className="size-3.5 text-primary" />,
answer: <Bot className="size-3.5 text-primary" />,
clarify: <CircleHelp className="size-3.5 text-amber-500" />,
assist: <CircleHelp className="size-3.5 text-amber-500" />,
ack: <BadgeCheck className="size-3.5 text-emerald-500" />,
notice: <Info className="size-3.5 text-muted-foreground" />,
};
@@ -184,17 +307,227 @@ export function ChatBubble({ msg }: { msg: Pick<ChatFeedMessage, 'role' | 'kind'
<div className="mt-1 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted">
{iconByKind[msg.kind]}
</div>
<div
className={cn(
'max-w-[85%] rounded-2xl rounded-bl-sm px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap',
msg.kind === 'clarify' && 'border border-amber-200 bg-amber-50/60 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200',
msg.kind === 'notice' && 'bg-muted/60 text-muted-foreground',
msg.kind === 'ack' && 'bg-emerald-50/70 text-emerald-800 dark:bg-emerald-950/20 dark:text-emerald-200',
(msg.kind === 'answer' || msg.kind === 'text') && 'bg-muted text-foreground',
)}
>
{msg.text}
</div>
{msg.kind === 'assist' && msg.clarification ? (
<ChatClarificationCard msg={msg} onSubmit={onClarificationSubmit} />
) : msg.kind === 'answer' ? (
<div className="min-w-0 max-w-[88%] space-y-2">
<div className="rounded-2xl rounded-bl-sm bg-muted px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap">
{msg.text || (msg.streaming ? '正在生成回答…' : '')}
{msg.streaming && (
<span className="ml-0.5 inline-block h-3 w-0.5 animate-pulse align-text-bottom bg-primary/60" />
)}
</div>
<AnswerSuggestionChips
msg={msg}
onSuggestionClick={onSuggestionClick}
onDismiss={onSuggestionsDismiss}
/>
</div>
) : (
<div
className={cn(
'max-w-[85%] rounded-2xl rounded-bl-sm px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap',
msg.kind === 'clarify' && 'border border-amber-200 bg-amber-50/60 text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200',
msg.kind === 'notice' && 'bg-muted/60 text-muted-foreground',
msg.kind === 'ack' && 'bg-emerald-50/70 text-emerald-800 dark:bg-emerald-950/20 dark:text-emerald-200',
msg.kind === 'text' && 'bg-muted text-foreground',
)}
>
{msg.text}
</div>
)}
</div>
);
}
function AnswerSuggestionChips({
msg,
onSuggestionClick,
onDismiss,
}: {
msg: ChatFeedMessage;
onSuggestionClick?: (suggestion: string) => void;
onDismiss?: (messageId: string) => void;
}) {
const suggestions = (msg.suggestions ?? []).filter((item) => item.trim());
if (msg.suggestionsHidden || (!msg.suggestionsLoading && suggestions.length === 0)) {
return null;
}
return (
<div className="rounded-xl border border-border/70 bg-background/80 p-2 shadow-sm">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Sparkles className="size-3 text-primary/70" />
<span className="truncate">继续问</span>
<button
type="button"
className="ml-auto rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="关闭推荐问题"
onClick={() => onDismiss?.(msg.id)}
>
<X className="size-3" />
</button>
</div>
{msg.suggestionsLoading ? (
<div className="flex flex-wrap gap-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-6 w-20 animate-pulse rounded-full bg-muted"
style={{ animationDelay: `${i * 120}ms` }}
/>
))}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="max-w-full rounded-full border border-input bg-background px-2.5 py-1 text-left text-[11px] leading-snug text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/5 hover:text-foreground"
onClick={() => onSuggestionClick?.(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
</div>
);
}
function ChatClarificationCard({
msg,
onSubmit,
}: {
msg: ChatFeedMessage;
onSubmit?: (messageId: string, answer: string) => void;
}) {
const payload = msg.clarification;
const [selection, setSelection] = useState<ClarificationSelection>({ selectedIds: [], custom: '' });
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === 'string' && option.label.trim(),
),
[payload?.options],
);
if (!payload) return null;
const allowMultiple = resolveAllowMultiple(payload.allow_multiple, payload.clarification_type);
const disabled = Boolean(msg.answered);
const canUseCustom = payload.allow_custom !== false;
const buildAnswer = (next: ClarificationSelection = selection) => {
const selected = options
.filter((option) => next.selectedIds.includes(option.id || option.label))
.map((option) => option.label.trim())
.filter(Boolean);
const custom = next.custom.trim();
return [...selected, custom].filter(Boolean).join('\n');
};
const canSubmit = Boolean(buildAnswer());
const handleOptionClick = (optionId: string) => {
if (disabled) return;
const nextIds = allowMultiple
? selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId]
: [optionId];
setSelection((prev) => ({ ...prev, selectedIds: nextIds }));
};
const handleSubmit = () => {
if (disabled) return;
const answer = buildAnswer();
if (!answer) return;
onSubmit?.(msg.id, answer);
};
return (
<div className="w-full max-w-[92%] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm">
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span>需要你的确认</span>
{disabled && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
已回答
</span>
)}
</div>
{payload.context ? (
<p className="text-sm leading-6 text-muted-foreground">{payload.context}</p>
) : null}
<div className="text-sm font-medium leading-6">
{payload.question || msg.text}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
<div className="text-xs text-muted-foreground">
{allowMultiple ? '可多选(再次点击取消选择)' : '请选择一项'}
</div>
{options.map((option) => {
const optionId = option.id || option.label;
const selected = selection.selectedIds.includes(optionId);
return (
<Button
key={optionId}
type="button"
variant={selected ? 'default' : 'outline'}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => handleOptionClick(optionId)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span
className={cn(
'text-xs font-normal leading-5',
selected ? 'text-primary-foreground/80' : 'text-muted-foreground',
)}
>
{option.description}
</span>
) : null}
</span>
{selected ? <Check className="ml-2 size-4 shrink-0" /> : null}
</Button>
);
})}
</div>
) : null}
{canUseCustom ? (
<div className="mt-4">
<Textarea
value={selection.custom}
onChange={(event) => setSelection((prev) => ({ ...prev, custom: event.currentTarget.value }))}
placeholder={
options.length > 0
? '可选:补充说明(与所选项一起发送),或在此填写自定义回答'
: '如果以上选项都不合适,可以在这里输入你的回答'
}
disabled={disabled}
className="min-h-20 resize-none"
/>
</div>
) : null}
{!disabled ? (
<div className="mt-3 flex justify-end">
<Button type="button" size="sm" disabled={!canSubmit} onClick={handleSubmit}>
<Send className="size-4" />
发送回答
</Button>
</div>
) : null}
</div>
);
}
@@ -8,12 +8,12 @@
* **一条信息流**——用户发消息后,由它触发的检索/操作事件出现在消息下方;
* - 干预卡换成 `ChatInterventionCards`(保留按钮、去掉内嵌输入框);
* - 底部统一智能输入框:自然语言经 `fastPathIntent`(规则快路径)/
* `parseWritingIntent`(后端 LLM)翻译成 `submitIntervention` / 控制指令 /
* `streamWritingIntent`(后端 LLM SSE)翻译成 `submitIntervention` / 控制指令 /
* 问答旁路 / 澄清追问。
*
* 引擎层完全复用 `AIWritingContext`(状态机 + LangGraph useStream),不改旧页面。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
Bot, ChevronsDown, CloudUpload, Loader2, Pause, Play, Plus, RotateCcw,
} from 'lucide-react';
@@ -35,10 +35,13 @@ import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { suspendAIWritingToBackground } from '../../api/ai-writing-sessions';
import {
fetchWritingAnswerSuggestions,
fastPathIntent,
INTENT_CONFIDENCE_THRESHOLD,
parseWritingIntent,
streamWritingAnswer,
streamWritingIntent,
type WritingIntentResult,
type WritingSuggestionMessage,
} from '../../api/ai-writing-intent';
import { countArticleWords } from '../../utils/wordCount';
import type { UserInterventionPayload } from '../../types/ai-writing';
@@ -108,6 +111,30 @@ const STAGE_UI: Record<string, { expert: string; placeholder: string; chips: str
},
};
const QA_HINT_RE = /(介绍|概括|总结|讲讲|说说|说明|解释|分析一下|这篇文章|文章内容|主要内容|主题|标题|大纲|结构|用了哪些素材|引用了哪些|现在到哪|进度|多少字|为什么|是什么|有哪些|吗|呢|\?|?)/;
const ACTION_HINT_RE = /(搜索|检索|补充|确认|继续|审核|送审|修改|改一下|重写|定稿|采纳|接受|删除|只用|不用|重新规划|后台|挂起|暂停|恢复|新增|增加|添加|加入|加上|补上|生成|撰写|写成|扩写|缩写|精简|润色|调整|拆成|合并|挪到|移动|规划)/;
const WRITING_ACTION_VERB_RE = /(新增|增加|添加|加入|加上|补充|补上|补个|补一个|生成|撰写|写一|写个|写成|扩写|缩写|精简|改写|润色|调整|修改|重写|删除|删掉|拆成|合并|挪到|移动|规划|重新规划|设计|安排)/;
const WRITING_ACTION_TARGET_RE = /(第[一二三四五六七八九十\d]+[章节段]|章节|章|小节|段落|段|结尾|开头|标题|大纲|内容|案例|表格|总结章|总结章节|结论|摘要|观点|部分)/;
const INFO_QUESTION_RE = /(哪些|什么|为什么|是什么|有哪些|多少|是否)/;
function looksLikeWritingActionRequest(text: string): boolean {
if (INFO_QUESTION_RE.test(text)) return false;
return WRITING_ACTION_VERB_RE.test(text) && WRITING_ACTION_TARGET_RE.test(text);
}
function shouldStreamAsAnswer(text: string): boolean {
const t = text.trim();
if (!t) return false;
if (looksLikeWritingActionRequest(t)) return false;
if (/(介绍|概括|总结|讲讲|说说|说明|解释).*(文章|这篇|内容|主题|大纲|结构)/.test(t)) return true;
if (/(这篇文章|文章内容|当前文章).*(讲|写|内容|主题|结构|大纲|标题|多少字)/.test(t)) return true;
if (/(用了哪些素材|引用了哪些|现在到哪|进度|到哪一步)/.test(t)) return true;
if ((t.includes('?') || t.includes('?') || /(为什么|是什么|有哪些|吗|呢)$/.test(t)) && !ACTION_HINT_RE.test(t)) {
return true;
}
return QA_HINT_RE.test(t) && !ACTION_HINT_RE.test(t);
}
/** 素材不足自动重搜后待续跑的原始意图(仅 material_confirm 阶段,graph 支持范围内)。 */
interface PendingIntent {
payload: UserInterventionPayload;
@@ -160,7 +187,13 @@ export function WritingChatPanel() {
// ── 意图层本地状态 ──────────────────────────────────────────────────────────
const [chatMessages, setChatMessages] = useState<ChatFeedMessage[]>([]);
const chatMessagesRef = useRef<ChatFeedMessage[]>([]);
const [resolving, setResolving] = useState(false);
const [intentParsing, setIntentParsing] = useState(false);
const [intentStreamText, setIntentStreamText] = useState('');
const [intentThinkingText, setIntentThinkingText] = useState('');
const intentAbortRef = useRef<AbortController | null>(null);
const answerAbortRef = useRef<AbortController | null>(null);
const [pendingIntent, setPendingIntent] = useState<PendingIntent | null>(null);
const pendingIntentRef = useRef<PendingIntent | null>(null);
pendingIntentRef.current = pendingIntent;
@@ -168,99 +201,248 @@ export function WritingChatPanel() {
const clarifyContextRef = useRef<{ originalText: string; question: string } | null>(null);
// 由输入框发起的干预序号:ChatFlowTimeline 不再重复渲染「我的要求」卡(气泡已按序展示原话)
const suppressedRequirementsRef = useRef<Set<number>>(new Set());
// 消息锚点:追加消息时记录当前 progressEvents 长度,渲染时插到对应事件之后
// 由输入框发起的完成卡锚点:完成卡跟在该轮对话气泡后面,而不是回填到气泡上方。
const completedInterventionAnchorsRef = useRef<Map<number, number>>(new Map());
// 消息锚点:追加消息时记录当前信息流长度(流程事件 + 已出现的稳定尾部槽位),
// 渲染时插到对应位置之后。这样“修改完成”等尾部卡片出现后再发消息,会排在卡片下面。
const eventsLenRef = useRef(0);
eventsLenRef.current = state.progressEvents.length;
eventsLenRef.current = state.progressEvents.length + (state.revisionSummary ? 1 : 0);
const completedLenRef = useRef(0);
completedLenRef.current = state.completedInterventions.length;
chatMessagesRef.current = chatMessages;
const [progressSession, setProgressSession] = useState<AIWritingSessionSummary | null>(null);
const appendMsg = useCallback((msg: { role: 'user' | 'assistant'; kind: ChatKind; text: string }) => {
setChatMessages((prev) => [...prev, { ...msg, id: uuid(), anchor: eventsLenRef.current }]);
useEffect(() => () => {
intentAbortRef.current?.abort();
answerAbortRef.current?.abort();
}, []);
/** 由输入框发起的干预:提交前登记序号,抑制时间线里重复的「我的要求」卡。 */
const submitFromComposer = useCallback(async (payload: UserInterventionPayload) => {
suppressedRequirementsRef.current.add(completedLenRef.current);
await submitIntervention(payload);
}, [submitIntervention]);
const [progressSession, setProgressSession] = useState<AIWritingSessionSummary | null>(null);
const isIdle = state.status === 'idle';
const isDone = state.status === 'done';
const isError = state.status === 'error';
const isRunning = !isIdle && !isDone && !isError;
const shouldAutoFollow = !isIdle && !isHistoryMode;
const pausePoint = state.currentPause?.pausePoint ?? null;
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
// ── 自动滚底(与旧面板同套逻辑) ────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAutoScrollingRef = useRef(false);
const autoScrollRef = useRef(true);
const shouldAutoFollowRef = useRef(shouldAutoFollow);
shouldAutoFollowRef.current = shouldAutoFollow;
const scrollbarDragRef = useRef(false);
const touchYRef = useRef<number | null>(null);
const [autoScroll, setAutoScroll] = useState(true);
const enableAutoScroll = useCallback(() => {
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
const disableAutoScroll = useCallback(() => {
autoScrollRef.current = false;
setAutoScroll(false);
}, []);
useEffect(() => {
if (isRunning && !state.isPaused) {
autoScrollRef.current = true;
setAutoScroll(true);
}
}, [isRunning, state.isPaused]);
const scrollToBottomNow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
const jump = () => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ block: 'end' });
} else {
el.scrollTop = el.scrollHeight;
}
};
jump();
requestAnimationFrame(() => {
jump();
requestAnimationFrame(() => {
isAutoScrollingRef.current = false;
});
});
}, []);
const maybeAutoScrollToBottom = useCallback(() => {
if (!autoScrollRef.current || !shouldAutoFollowRef.current) return;
scrollToBottomNow();
}, [scrollToBottomNow]);
// 新会话 / 重置后恢复自动滚底
useEffect(() => {
if (isIdle) {
enableAutoScroll();
}
}, [enableAutoScroll, isIdle]);
// 开始写作 / 点击继续后恢复自动滚底
useEffect(() => {
if (shouldAutoFollow && !state.isPaused) {
enableAutoScroll();
requestAnimationFrame(scrollToBottomNow);
}
}, [enableAutoScroll, scrollToBottomNow, shouldAutoFollow, state.isPaused]);
// 监听用户手动滚动。只把明确的用户上划/拖动滚动条视为退出跟随;
// 流式文本、卡片展开、右侧布局变化造成的 scroll 事件不误伤自动滚底。
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const nearBottom = () => el.scrollHeight - el.scrollTop - el.clientHeight < 24;
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) disableAutoScroll();
};
const onTouchStart = (e: TouchEvent) => {
touchYRef.current = e.touches[0]?.clientY ?? null;
};
const onTouchMove = (e: TouchEvent) => {
const prevY = touchYRef.current;
const nextY = e.touches[0]?.clientY ?? null;
if (prevY !== null && nextY !== null && nextY > prevY + 4) {
disableAutoScroll();
}
touchYRef.current = nextY;
};
const onPointerDown = (e: PointerEvent) => {
if (e.target === el) {
scrollbarDragRef.current = true;
}
};
const onPointerDone = () => {
scrollbarDragRef.current = false;
};
let lastTop = el.scrollTop;
const onScroll = () => {
const cur = el.scrollTop;
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
if (isAutoScrollingRef.current) {
lastTop = cur;
return;
}
if (nearBottom()) {
enableAutoScroll();
} else if (scrollbarDragRef.current && cur < lastTop - 2) {
disableAutoScroll();
}
lastTop = cur;
};
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: true });
el.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerDone);
window.addEventListener('pointercancel', onPointerDone);
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerDone);
window.removeEventListener('pointercancel', onPointerDone);
el.removeEventListener('scroll', onScroll);
};
}, [disableAutoScroll]);
}, [disableAutoScroll, enableAutoScroll]);
// 内容高度变化兜底:对话气泡、流式文本、折叠卡片展开都可能不改变事件数量。
useEffect(() => {
if (!autoScrollRef.current || !scrollRef.current) return;
if (!isRunning && chatMessages.length === 0) return;
const el = scrollRef.current;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
if (!el || typeof MutationObserver === 'undefined') return;
const observer = new MutationObserver(() => {
maybeAutoScrollToBottom();
});
observer.observe(el, { childList: true, subtree: true, characterData: true });
return () => observer.disconnect();
}, [maybeAutoScrollToBottom]);
useLayoutEffect(() => {
maybeAutoScrollToBottom();
}, [
maybeAutoScrollToBottom,
autoScroll,
isRunning,
shouldAutoFollow,
state.status,
state.progressEvents.length,
state.completedInterventions.length,
state.streamingDraftSections.length,
state.streamingReviewItems.length,
state.outlineStreamingText,
state.outlineThinkingText,
state.skillSearchSteps.length,
state.skillSearchPending,
state.skillSelectStreamingText,
state.skillSelectThinkingText,
state.intentStreamingText,
state.intentThinkingText,
state.parsedIntent,
state.revisionSummary,
state.currentPause,
chatMessages.length,
resolving,
intentParsing,
intentStreamText,
intentThinkingText,
pendingIntent,
]);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
isAutoScrollingRef.current = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
autoScrollRef.current = true;
setAutoScroll(true);
}, []);
enableAutoScroll();
scrollToBottomNow();
}, [enableAutoScroll, scrollToBottomNow]);
const forceAutoScrollToBottom = useCallback(() => {
enableAutoScroll();
scrollToBottomNow();
requestAnimationFrame(scrollToBottomNow);
}, [enableAutoScroll, scrollToBottomNow]);
const appendMsg = useCallback((
msg: {
role: 'user' | 'assistant';
kind: ChatKind;
text: string;
clarification?: ChatFeedMessage['clarification'];
answered?: boolean;
suggestions?: string[];
suggestionsLoading?: boolean;
suggestionsHidden?: boolean;
streaming?: boolean;
},
options: { forceScroll?: boolean; anchor?: number } = {},
) => {
const id = uuid();
setChatMessages((prev) => [
...prev,
{ ...msg, id, anchor: options.anchor ?? eventsLenRef.current },
]);
if (options.forceScroll) {
forceAutoScrollToBottom();
}
return id;
}, [forceAutoScrollToBottom]);
/** 由输入框发起的干预:提交前登记序号,抑制时间线里重复的「我的要求」卡。 */
const submitFromComposer = useCallback(async (
payload: UserInterventionPayload,
anchor = eventsLenRef.current,
) => {
const interventionIdx = completedLenRef.current;
suppressedRequirementsRef.current.add(interventionIdx);
completedInterventionAnchorsRef.current.set(interventionIdx, anchor);
await submitIntervention(payload);
}, [submitIntervention]);
// ── 历史 / 会话操作 ─────────────────────────────────────────────────────────
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
@@ -268,10 +450,19 @@ export function WritingChatPanel() {
}, [openHistory]);
const clearChatState = useCallback(() => {
intentAbortRef.current?.abort();
answerAbortRef.current?.abort();
intentAbortRef.current = null;
answerAbortRef.current = null;
setChatMessages([]);
setIntentStreamText('');
setIntentThinkingText('');
setIntentParsing(false);
setResolving(false);
setPendingIntent(null);
clarifyContextRef.current = null;
suppressedRequirementsRef.current = new Set();
completedInterventionAnchorsRef.current = new Map();
}, []);
const handleNewSession = useCallback(() => {
@@ -312,20 +503,162 @@ export function WritingChatPanel() {
})();
}, [state.sessionId, reset, refresh, markBackground, clearBackground, clearChatState]);
const requestAnswerSuggestions = useCallback((
messageId: string,
userText: string,
answerText: string,
) => {
const sid = state.sessionId;
if (!sid || isHistoryMode) return;
const recent: WritingSuggestionMessage[] = chatMessagesRef.current
.filter((msg) => msg.text.trim() && (msg.role === 'user' || msg.kind === 'answer'))
.slice(-4)
.map((msg) => ({
role: (msg.role === 'user' ? 'user' : 'assistant') as WritingSuggestionMessage['role'],
content: msg.text,
}));
const candidateMessages: WritingSuggestionMessage[] = [
...recent,
{ role: 'user', content: userText },
{ role: 'assistant', content: answerText },
];
const messages = candidateMessages.filter((msg, index, arr) => (
Boolean(msg.content.trim())
&& arr.findIndex((item) => item.role === msg.role && item.content === msg.content) === index
));
setChatMessages((prev) => prev.map((msg) => (
msg.id === messageId
? { ...msg, suggestionsLoading: true, suggestions: [], suggestionsHidden: false }
: msg
)));
void fetchWritingAnswerSuggestions(sid, {
messages,
n: 3,
modelName: state.request?.modelName ?? null,
})
.then((suggestions) => {
setChatMessages((prev) => prev.map((msg) => (
msg.id === messageId
? {
...msg,
suggestionsLoading: false,
suggestions,
suggestionsHidden: suggestions.length === 0 ? true : msg.suggestionsHidden,
}
: msg
)));
})
.catch(() => {
setChatMessages((prev) => prev.map((msg) => (
msg.id === messageId
? { ...msg, suggestionsLoading: false, suggestions: [], suggestionsHidden: true }
: msg
)));
});
}, [isHistoryMode, state.request?.modelName, state.sessionId]);
const streamAnswerFromComposer = useCallback(async (
question: string,
turnAnchor: number,
) => {
const sid = state.sessionId;
if (!sid) return;
answerAbortRef.current?.abort();
const controller = new AbortController();
answerAbortRef.current = controller;
const answerMsgId = appendMsg(
{ role: 'assistant', kind: 'answer', text: '', streaming: true },
{ anchor: turnAnchor, forceScroll: true },
);
let answerText = '';
setResolving(true);
try {
for await (const event of streamWritingAnswer(
sid,
{ text: question, status: state.status, pausePoint, modelName: state.request?.modelName },
controller.signal,
)) {
if (controller.signal.aborted) return;
if (event.type === 'start' || event.type === 'thinking') {
continue;
}
if (event.type === 'text') {
answerText += event.chunk;
setChatMessages((prev) => prev.map((msg) => (
msg.id === answerMsgId ? { ...msg, text: msg.text + event.chunk } : msg
)));
forceAutoScrollToBottom();
} else if (event.type === 'error') {
throw new Error(event.message);
}
}
const finalText = answerText.trim();
setChatMessages((prev) => prev.map((msg) => (
msg.id === answerMsgId
? {
...msg,
text: finalText || '当前上下文里没有足够信息回答这个问题。',
streaming: false,
}
: msg
)));
if (finalText) {
requestAnswerSuggestions(answerMsgId, question, finalText);
}
} catch (e) {
if (controller.signal.aborted) return;
setChatMessages((prev) => prev.map((msg) => (
msg.id === answerMsgId
? {
...msg,
kind: 'notice',
text: `回答生成暂时不可用(${e instanceof Error ? e.message : '未知错误'})。`,
streaming: false,
}
: msg
)));
} finally {
if (answerAbortRef.current === controller) {
answerAbortRef.current = null;
}
setResolving(false);
}
}, [appendMsg, forceAutoScrollToBottom, pausePoint, requestAnswerSuggestions, state.request?.modelName, state.sessionId, state.status]);
// ── 执行意图结果 ────────────────────────────────────────────────────────────
const executeIntent = useCallback(async (result: WritingIntentResult, originalText: string) => {
const executeIntent = useCallback(async (
result: WritingIntentResult,
originalText: string,
turnAnchor?: number,
) => {
const appendTurnMsg = (msg: {
role: 'assistant';
kind: ChatKind;
text: string;
clarification?: ChatFeedMessage['clarification'];
}) => {
return appendMsg(msg, { anchor: turnAnchor, forceScroll: true });
};
// 控制类:由前端直接执行
if (result.intent === 'resume_writing') {
appendMsg({ role: 'assistant', kind: 'ack', text: '好的,继续从上次的进度写作' });
appendTurnMsg({ role: 'assistant', kind: 'ack', text: '好的,继续从上次的进度写作' });
resumeWriting();
return;
}
if (result.intent === 'pause_writing') {
if (isRunning && !state.isPaused) {
appendMsg({ role: 'assistant', kind: 'ack', text: '好的,已暂停(可随时说「继续」恢复)' });
appendTurnMsg({ role: 'assistant', kind: 'ack', text: '好的,已暂停(可随时说「继续」恢复)' });
pauseWriting();
} else {
appendMsg({ role: 'assistant', kind: 'notice', text: '当前没有正在进行的写作可暂停' });
appendTurnMsg({ role: 'assistant', kind: 'notice', text: '当前没有正在进行的写作可暂停' });
}
return;
}
@@ -336,7 +669,8 @@ export function WritingChatPanel() {
// 问答旁路:直接渲染回答,不动写作状态
if (result.answer && !result.action) {
appendMsg({ role: 'assistant', kind: 'answer', text: result.answer });
const answerMsgId = appendTurnMsg({ role: 'assistant', kind: 'answer', text: result.answer });
requestAnswerSuggestions(answerMsgId, originalText, result.answer);
clarifyContextRef.current = null;
return;
}
@@ -344,13 +678,24 @@ export function WritingChatPanel() {
// 澄清:反问并记住上下文,等用户下一句
if (result.clarify || (result.action && result.confidence < INTENT_CONFIDENCE_THRESHOLD)) {
const question = result.clarify || '我理解得不够确定,能再说得具体一点吗?';
appendMsg({ role: 'assistant', kind: 'clarify', text: question });
appendTurnMsg({
role: 'assistant',
kind: 'assist',
text: question,
clarification: {
question,
context: '我需要你再补充一点信息,确认后我会继续处理这条写作指令。',
allow_custom: true,
allow_multiple: false,
options: [],
},
});
clarifyContextRef.current = { originalText, question };
return;
}
if (!result.action || !result.payload) {
appendMsg({
appendTurnMsg({
role: 'assistant', kind: 'notice',
text: '我没有识别出可执行的操作。可以点击上方卡片里的按钮,或换一种说法。',
});
@@ -361,14 +706,14 @@ export function WritingChatPanel() {
const pause = state.currentPause;
if (!pause) {
appendMsg({
appendTurnMsg({
role: 'assistant', kind: 'notice',
text: 'AI 正在处理当前阶段,暂时不能执行该操作。等这一步完成暂停后,我会再等你确认。',
});
return;
}
if (result.pausePoint && result.pausePoint !== pause.pausePoint) {
appendMsg({
appendTurnMsg({
role: 'assistant', kind: 'notice',
text: '流程刚刚推进到了新的阶段,请针对当前阶段重新说明你的要求。',
});
@@ -389,11 +734,14 @@ export function WritingChatPanel() {
createdAtPause: pause,
};
setPendingIntent(pending);
appendMsg({
appendTurnMsg({
role: 'assistant', kind: 'ack',
text: `这条要求需要补充素材支撑,我先按「${result.researchQuery}」补充检索,素材到位后自动继续执行你的要求。`,
});
await submitFromComposer({ action: 're_search', approvedMaterialIds: [], userQuery: result.researchQuery });
await submitFromComposer(
{ action: 're_search', approvedMaterialIds: [], userQuery: result.researchQuery },
turnAnchor,
);
return;
}
@@ -402,21 +750,25 @@ export function WritingChatPanel() {
if (result.needResearch && pause.pausePoint !== 'material_confirm') {
advisory = '(提示:这条要求可能需要现有素材之外的信息;若写作时素材不足,AI 会自动向你求助补充检索)';
}
appendMsg({ role: 'assistant', kind: 'ack', text: advisory ? `${ack}\n${advisory}` : ack });
await submitFromComposer(result.payload);
}, [appendMsg, handleBackgroundSuspend, isRunning, pauseWriting, resumeWriting, state.currentPause, state.isPaused, submitFromComposer]);
appendTurnMsg({ role: 'assistant', kind: 'ack', text: advisory ? `${ack}\n${advisory}` : ack });
await submitFromComposer(result.payload, turnAnchor);
}, [appendMsg, handleBackgroundSuspend, isRunning, pauseWriting, requestAnswerSuggestions, resumeWriting, state.currentPause, state.isPaused, submitFromComposer]);
// ── 底部输入框发送 ──────────────────────────────────────────────────────────
const handleSend = useCallback(async (rawText: string) => {
const text = rawText.trim();
if (!text || resolving || isIdle || isHistoryMode) return;
appendMsg({ role: 'user', kind: 'text', text });
const turnAnchor = eventsLenRef.current;
appendMsg({ role: 'user', kind: 'text', text }, { anchor: turnAnchor, forceScroll: true });
// 有 pending 意图时,用户又发新话 → 视为改主意,丢弃旧 pending
if (pendingIntentRef.current) {
setPendingIntent(null);
appendMsg({ role: 'assistant', kind: 'notice', text: '已放弃之前待续跑的要求,按你的新指示处理。' });
appendMsg(
{ role: 'assistant', kind: 'notice', text: '已放弃之前待续跑的要求,按你的新指示处理。' },
{ anchor: turnAnchor, forceScroll: true },
);
}
// 规则快路径(零延迟;澄清上下文存在时跳过,让 LLM 结合上下文理解)
@@ -427,13 +779,21 @@ export function WritingChatPanel() {
blockedSections: state.currentPause?.blockedSections,
});
if (fast) {
await executeIntent(fast, text);
await executeIntent(fast, text, turnAnchor);
return;
}
}
if (!state.sessionId) {
appendMsg({ role: 'assistant', kind: 'notice', text: '会话尚未建立,请稍候片刻再试。' });
appendMsg(
{ role: 'assistant', kind: 'notice', text: '会话尚未建立,请稍候片刻再试。' },
{ anchor: turnAnchor, forceScroll: true },
);
return;
}
if (!clarifyContextRef.current && shouldStreamAsAnswer(text)) {
await streamAnswerFromComposer(text, turnAnchor);
return;
}
@@ -443,23 +803,82 @@ export function WritingChatPanel() {
? `此前用户说:「${clarifyCtx.originalText}」\n系统追问:「${clarifyCtx.question}」\n用户现在回答:「${text}」`
: text;
intentAbortRef.current?.abort();
const controller = new AbortController();
intentAbortRef.current = controller;
setIntentStreamText('');
setIntentThinkingText('');
setIntentParsing(true);
setResolving(true);
try {
const result = await parseWritingIntent(state.sessionId, {
text: effectiveText,
status: state.status,
pausePoint,
});
await executeIntent(result, text);
let result: WritingIntentResult | null = null;
for await (const event of streamWritingIntent(
state.sessionId,
{
text: effectiveText,
status: state.status,
pausePoint,
},
controller.signal,
)) {
if (controller.signal.aborted) return;
if (event.type === 'thinking') {
setIntentThinkingText((prev) => prev + event.chunk);
} else if (event.type === 'text') {
// 这里的正文通常是结构化 JSON,只用于后端解析;窄栏不直接展示。
void event.chunk;
} else if (event.type === 'result') {
result = event.result;
setIntentParsing(false);
setIntentStreamText('');
setIntentThinkingText('');
} else if (event.type === 'error') {
throw new Error(event.message);
}
}
if (!result) {
throw new Error('意图解析没有返回结果');
}
await executeIntent(result, text, turnAnchor);
} catch (e) {
appendMsg({
role: 'assistant', kind: 'notice',
text: `意图解析暂时不可用(${e instanceof Error ? e.message : '未知错误'})。可以直接点击上方卡片里的按钮继续操作。`,
});
if (controller.signal.aborted) return;
setIntentParsing(false);
appendMsg(
{
role: 'assistant', kind: 'notice',
text: `意图解析暂时不可用(${e instanceof Error ? e.message : '未知错误'})。可以直接点击上方卡片里的按钮继续操作。`,
},
{ anchor: turnAnchor, forceScroll: true },
);
} finally {
if (intentAbortRef.current === controller) {
intentAbortRef.current = null;
}
setResolving(false);
setIntentParsing(false);
setIntentStreamText('');
setIntentThinkingText('');
}
}, [appendMsg, executeIntent, isHistoryMode, isIdle, pausePoint, resolving, state.currentPause, state.isPaused, state.sessionId, state.status]);
}, [appendMsg, executeIntent, isHistoryMode, isIdle, pausePoint, resolving, state.currentPause, state.isPaused, state.sessionId, state.status, streamAnswerFromComposer]);
const handleClarificationSubmit = useCallback((messageId: string, answer: string) => {
const text = answer.trim();
if (!text || resolving || isIdle || isHistoryMode) return;
setChatMessages((prev) => prev.map((msg) => (
msg.id === messageId ? { ...msg, answered: true } : msg
)));
void handleSend(text);
}, [handleSend, isHistoryMode, isIdle, resolving]);
const handleSuggestionClick = useCallback((suggestion: string) => {
void handleSend(suggestion);
}, [handleSend]);
const handleSuggestionsDismiss = useCallback((messageId: string) => {
setChatMessages((prev) => prev.map((msg) => (
msg.id === messageId ? { ...msg, suggestionsHidden: true } : msg
)));
}, []);
// ── 素材补充完成 → 自动续跑 pending 意图 ────────────────────────────────────
const autoResumeBusyRef = useRef(false);
@@ -471,11 +890,15 @@ export function WritingChatPanel() {
if (state.currentPause === pending.createdAtPause) return;
autoResumeBusyRef.current = true;
setPendingIntent(null);
appendMsg({
role: 'assistant', kind: 'ack',
text: `素材已补充完成,继续执行你之前的要求:「${pending.note}」`,
});
void submitFromComposer(pending.payload).finally(() => {
const turnAnchor = eventsLenRef.current;
appendMsg(
{
role: 'assistant', kind: 'ack',
text: `素材已补充完成,继续执行你之前的要求:「${pending.note}」`,
},
{ anchor: turnAnchor, forceScroll: true },
);
void submitFromComposer(pending.payload, turnAnchor).finally(() => {
autoResumeBusyRef.current = false;
});
}, [state.currentPause, appendMsg, submitFromComposer]);
@@ -594,7 +1017,13 @@ export function WritingChatPanel() {
{/* 主体滚动区 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
<div
ref={scrollRef}
className={cn(
'absolute inset-0 overflow-y-auto',
autoScroll && '[overflow-anchor:none]',
)}
>
{isHistoryMode && historySession && (
<AIWritingHistoryView session={historySession} />
)}
@@ -615,6 +1044,13 @@ export function WritingChatPanel() {
currentPause={state.currentPause}
chatMessages={chatMessages}
suppressedRequirementIndices={suppressedRequirementsRef.current}
completedInterventionAnchors={completedInterventionAnchorsRef.current}
onClarificationSubmit={handleClarificationSubmit}
onSuggestionClick={handleSuggestionClick}
onSuggestionsDismiss={handleSuggestionsDismiss}
revisionSummarySlot={
state.revisionSummary ? <RevisionSummaryCard summary={state.revisionSummary} /> : undefined
}
currentInterventionSlot={
state.currentPause ? (
<ChatInterventionCard
@@ -647,11 +1083,6 @@ export function WritingChatPanel() {
{!isHistoryMode && state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
<ReviewStreamingView items={state.streamingReviewItems} />
)}
{!isHistoryMode && state.revisionSummary && (
<div className="px-4 pb-2">
<RevisionSummaryCard summary={state.revisionSummary} />
</div>
)}
{!isHistoryMode && state.status === 'writing_draft' && (
<DraftGeneratingView sections={state.streamingDraftSections} />
)}
@@ -695,8 +1126,11 @@ export function WritingChatPanel() {
</div>
)}
{/* 瞬态提示(正在理解意图 / pending 续跑),贴着输入框 */}
{!isHistoryMode && resolving && (
{/* 瞬态提示(仅意图解析阶段显示;动作提交/素材检索开始后立即收起) */}
{!isHistoryMode && intentParsing && intentThinkingText && (
<IntentStreamingView text="" thinkingText={intentThinkingText} />
)}
{!isHistoryMode && intentParsing && !intentThinkingText && (
<div className="flex items-center gap-2 px-4 pb-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
正在理解你的意图…
@@ -716,6 +1150,7 @@ export function WritingChatPanel() {
</button>
</div>
)}
<div ref={bottomRef} className="h-px" aria-hidden="true" />
</div>
{isRunning && !autoScroll && (
@@ -23,10 +23,12 @@ import tempfile
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.authz import require_auth
from app.gateway.deps import get_config
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
logger = logging.getLogger(__name__)
@@ -353,30 +355,53 @@ class IntentParseResponse(BaseModel):
clarify: str = ""
@router.post("/sessions/{session_id}/intent", response_model=IntentParseResponse)
async def parse_session_intent(session_id: str, body: IntentParseRequest, request: Request):
"""对话驱动写作台:把用户自由文本翻译成结构化写作指令(或问答/澄清)。
class WritingSuggestionMessage(BaseModel):
role: str
content: str
读该会话 graph checkpoint 的素材/大纲/草稿/审核摘要 + 当前 pending interrupt
(权威暂停点),交给 ``deerflow.agents.ai_writing.intent_router`` 的 LLM 意图
路由。动作白名单 / 高代价置信度门槛 / payload 清洗都在 intent_router 内完成,
这里只做鉴权与 state 读取。模型失败不抛 5xx —— intent_router 会降级成 clarify。
"""
from app.gateway.ai_writing_job_executor import _pending_interrupt
from app.gateway.deps import get_ai_writing_session_store, get_current_user
from deerflow.agents.ai_writing.intent_router import parse_user_intent
class WritingSuggestionsRequest(BaseModel):
messages: list[WritingSuggestionMessage] = Field(default_factory=list)
n: int = Field(default=3, ge=1, le=5)
model_name: Optional[str] = None
class WritingSuggestionsResponse(BaseModel):
suggestions: list[str] = Field(default_factory=list)
class AnswerStreamRequest(BaseModel):
text: str
status: Optional[str] = None
pause_point: Optional[str] = None
model_name: Optional[str] = None
async def _prepare_intent_parse_context(
session_id: str,
body: IntentParseRequest,
request: Request,
) -> tuple[str, dict, Optional[dict], Optional[str]]:
"""鉴权并读取会话 checkpoint,为一次性/流式意图解析提供同一份上下文。"""
text = (body.text or "").strip()
if not text:
raise HTTPException(status_code=400, detail="text 不能为空")
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
await _ensure_session_owner(session_id, request)
values, interrupt_value, pause_point = await _read_ai_writing_checkpoint_context(
session_id,
body.pause_point,
request,
)
return text, values, interrupt_value, pause_point
async def _read_ai_writing_checkpoint_context(
session_id: str,
fallback_pause_point: Optional[str],
request: Request,
) -> tuple[dict, Optional[dict], Optional[str]]:
from app.gateway.ai_writing_job_executor import _pending_interrupt
# 读 checkpoint:暂停点以 pending interrupt 为权威(frontend 声明只作读取失败兜底)。
values: dict = {}
@@ -400,7 +425,89 @@ async def parse_session_intent(session_id: str, body: IntentParseRequest, reques
except Exception:
logger.warning("ai_writing intent: read checkpoint failed for %s", session_id, exc_info=True)
if pause_point is None and not checkpoint_ok:
pause_point = (body.pause_point or "").strip() or None
pause_point = (fallback_pause_point or "").strip() or None
return values, interrupt_value, pause_point
async def _get_session_owner_record(session_id: str, request: Request) -> dict:
from app.gateway.deps import get_ai_writing_session_store, get_current_user
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
return record
async def _ensure_session_owner(session_id: str, request: Request) -> None:
await _get_session_owner_record(session_id, request)
def _clip_answer_context(text: str, limit: int) -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return text[:limit] + "\n…(内容过长已截断)"
def _build_session_answer_context(record: dict) -> str:
"""Build a fast context from the persisted session row for plain Q&A."""
parts: list[str] = []
title = str(record.get("title") or record.get("draft_title") or "").strip()
user_intent = str(record.get("user_intent") or "").strip()
status = str(record.get("status") or "").strip()
if title:
parts.append(f"会话标题:{title}")
if user_intent:
parts.append(f"写作主题:{_clip_answer_context(user_intent, 300)}")
if status:
parts.append(f"流程状态:{status}")
draft_markdown = str(record.get("draft_markdown") or "").strip()
has_substantive_context = bool(draft_markdown)
if draft_markdown:
draft_title = str(record.get("draft_title") or title or "").strip()
header = f"草稿标题:《{draft_title}》" if draft_title else "当前草稿"
parts.append(f"{header}\n--- 草稿正文 ---\n{_clip_answer_context(draft_markdown, 6000)}")
review = record.get("review_result") if isinstance(record.get("review_result"), dict) else {}
if review:
has_substantive_context = True
lines = [
f"编辑结论:{review.get('verdict', '')}(总分 {review.get('overall_score', review.get('overallScore', ''))})"
]
notes = review.get("revision_notes") or review.get("revisionNotes") or []
if isinstance(notes, list):
for i, note in enumerate(notes[:8]):
if not isinstance(note, dict):
continue
issue = _clip_answer_context(str(note.get("issue") or ""), 120)
suggestion = _clip_answer_context(str(note.get("suggestion") or ""), 120)
if issue or suggestion:
lines.append(f"意见 {i + 1}:{issue} → {suggestion}")
parts.append("\n".join(lines))
if not has_substantive_context:
return ""
return "\n\n".join(part for part in parts if part.strip())
@router.post("/sessions/{session_id}/intent", response_model=IntentParseResponse)
async def parse_session_intent(session_id: str, body: IntentParseRequest, request: Request):
"""对话驱动写作台:把用户自由文本翻译成结构化写作指令(或问答/澄清)。
读该会话 graph checkpoint 的素材/大纲/草稿/审核摘要 + 当前 pending interrupt
(权威暂停点),交给 ``deerflow.agents.ai_writing.intent_router`` 的 LLM 意图
路由。动作白名单 / 高代价置信度门槛 / payload 清洗都在 intent_router 内完成,
这里只做鉴权与 state 读取。模型失败不抛 5xx —— intent_router 会降级成 clarify。
"""
from deerflow.agents.ai_writing.intent_router import parse_user_intent
text, values, interrupt_value, pause_point = await _prepare_intent_parse_context(session_id, body, request)
result = await parse_user_intent(
text=text,
@@ -412,6 +519,231 @@ async def parse_session_intent(session_id: str, body: IntentParseRequest, reques
return IntentParseResponse(**result)
@router.post("/sessions/{session_id}/intent/stream")
async def stream_session_intent(session_id: str, body: IntentParseRequest, request: Request):
"""对话驱动写作台:流式意图解析。
SSE 事件:
- ``thinking``: ``{"chunk": "..."}``,模型推理 / ``<think>`` 内容
- ``text``: ``{"chunk": "..."}``,模型正文输出(通常是结构化 JSON)
- ``result``: 最终规范化后的 ``IntentParseResponse`` 字段
- ``done`` / ``error``
"""
from app.gateway.services import format_sse
from deerflow.agents.ai_writing.intent_router import stream_user_intent
text, values, interrupt_value, pause_point = await _prepare_intent_parse_context(session_id, body, request)
async def event_stream():
try:
async for event in stream_user_intent(
text=text,
pause_point=pause_point,
values=values,
interrupt_value=interrupt_value,
model_name=str(values.get("model_name") or "") or None,
):
if event.get("type") == "chunk":
chunk = str(event.get("chunk") or "")
if not chunk:
continue
yield format_sse(
"thinking" if event.get("is_thinking") else "text",
{"chunk": chunk},
)
elif event.get("type") == "result":
yield format_sse("result", event.get("result") or {})
yield format_sse("done", {})
except Exception:
logger.exception("ai_writing intent stream failed: session=%s", session_id)
yield format_sse("error", {"message": "意图解析流式接口异常,请稍后重试"})
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/sessions/{session_id}/suggestions", response_model=WritingSuggestionsResponse)
async def generate_session_suggestions(
session_id: str,
body: WritingSuggestionsRequest,
request: Request,
config=Depends(get_config),
):
"""为 AI 写作对话版生成窄栏追问建议。
与主对话页的 ``/api/threads/{id}/suggestions`` 语义一致,但鉴权对象换成
AI 写作 session,避免把写作 sessionId 冒充 workspace threadId。
"""
from langchain_core.messages import HumanMessage, SystemMessage
from app.gateway.routers.suggestions import (
_extract_response_text,
_format_conversation,
_parse_json_string_list,
SuggestionMessage,
)
from deerflow.models import create_chat_model
await _ensure_session_owner(session_id, request)
if not body.messages:
return WritingSuggestionsResponse()
messages = [
SuggestionMessage(role=m.role, content=m.content)
for m in body.messages
if (m.content or "").strip()
]
conversation = _format_conversation(messages)
if not conversation:
return WritingSuggestionsResponse()
n = body.n
system_instruction = (
"You are generating compact follow-up questions for a narrow AI writing sidebar.\n"
f"Based on the conversation below, produce EXACTLY {n} short questions the user might ask next.\n"
"Requirements:\n"
"- Questions must be relevant to the article or writing workflow.\n"
"- Prefer questions about summary, structure, evidence, style, revisions, or next actions.\n"
"- Questions must be written in the same language as the user.\n"
"- Keep each question very concise (<= 24 Chinese characters when Chinese).\n"
"- Do NOT include numbering, markdown, or any extra text.\n"
"- Output MUST be a JSON array of strings only.\n"
)
user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions"
try:
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config)
response = await model.ainvoke(
[SystemMessage(content=system_instruction), HumanMessage(content=user_content)],
config={"run_name": "ai_writing_suggestions"},
)
raw = _extract_response_text(response.content)
suggestions = _parse_json_string_list(raw) or []
cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()]
return WritingSuggestionsResponse(suggestions=cleaned[:n])
except Exception:
logger.exception("ai_writing: failed to generate suggestions: session=%s", session_id)
return WritingSuggestionsResponse()
@router.post("/sessions/{session_id}/answer/stream")
async def stream_session_answer(session_id: str, body: AnswerStreamRequest, request: Request):
"""AI 写作对话版问答旁路:纯文本 SSE 回答。
意图路由仍负责操作类消息;明显的提问走这里,避免把答案塞进 JSON 的
``answer`` 字段导致“模型完整生成后才一次性显示”。
"""
from app.gateway.services import format_sse
from deerflow.agents.ai_writing.intent_router import build_context_snapshot
from deerflow.agents.ai_writing.nodes._utils import ThinkTagParser, _content_to_text, reasoning_delta
from deerflow.models import create_chat_model
from langchain_core.messages import HumanMessage, SystemMessage
text = (body.text or "").strip()
if not text:
raise HTTPException(status_code=400, detail="text 不能为空")
record = await _get_session_owner_record(session_id, request)
session_context = _build_session_answer_context(record)
async def event_stream():
import time
started = time.monotonic()
parser = ThinkTagParser()
chunk_count = 0
first_chunk_logged = False
yield format_sse("start", {})
try:
values: dict = {}
if session_context:
context = session_context
model_name = (body.model_name or "").strip() or None
logger.info(
"ai_writing answer context ready: session=%s elapsed=%.3fs source=session_row pause_point=%s",
session_id,
time.monotonic() - started,
body.pause_point,
)
else:
values, interrupt_value, pause_point = await _read_ai_writing_checkpoint_context(
session_id,
body.pause_point,
request,
)
context = build_context_snapshot(values, pause_point, interrupt_value)
model_name = (body.model_name or "").strip() or str(values.get("model_name") or "") or None
logger.info(
"ai_writing answer context ready: session=%s elapsed=%.3fs source=checkpoint has_values=%s pause_point=%s",
session_id,
time.monotonic() - started,
bool(values),
pause_point,
)
messages = [
SystemMessage(content=(
"你是 AI 智能写作工作台里的问答助手。用户正在写一篇文章,"
"你只能根据给定的写作上下文回答问题,不执行确认、检索、修改、定稿等流程动作。"
"如果上下文没有足够信息,请明确说明,不要编造。回答用中文,简洁但有信息量。"
)),
HumanMessage(content=f"【写作上下文】\n{context}\n\n【用户问题】\n{text}\n\n请直接回答:"),
]
model = create_chat_model(
name=model_name,
thinking_enabled=False,
force_disable_thinking=True,
)
async for chunk in model.astream(messages, config={"run_name": "ai_writing_answer"}):
reasoning = reasoning_delta(chunk)
if reasoning:
if not first_chunk_logged:
first_chunk_logged = True
logger.info(
"ai_writing answer first chunk: session=%s elapsed=%.3fs kind=thinking",
session_id,
time.monotonic() - started,
)
yield format_sse("thinking", {"chunk": reasoning})
content = _content_to_text(getattr(chunk, "content", "") or "")
if not content:
continue
for is_thinking, part in parser.feed(content):
if not part:
continue
if not first_chunk_logged:
first_chunk_logged = True
logger.info(
"ai_writing answer first chunk: session=%s elapsed=%.3fs kind=%s",
session_id,
time.monotonic() - started,
"thinking" if is_thinking else "text",
)
chunk_count += 1
yield format_sse("thinking" if is_thinking else "text", {"chunk": part})
logger.info(
"ai_writing answer stream done: session=%s chunks=%d elapsed=%.3fs first_chunk=%s",
session_id,
chunk_count,
time.monotonic() - started,
first_chunk_logged,
)
yield format_sse("done", {})
except Exception:
logger.exception("ai_writing answer stream failed: session=%s", session_id)
yield format_sse("error", {"message": "回答生成失败,请稍后重试"})
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
class BackgroundResponse(BaseModel):
# 是否新拉起了后台任务(False = 已在后台跑,幂等返回)
started: bool
@@ -17,7 +17,7 @@ from __future__ import annotations
import logging
import re
from typing import Any
from typing import Any, AsyncIterator
from .prompts.intent_router_prompts import (
INTENT_ROUTER_SYSTEM,
@@ -48,6 +48,73 @@ ACTION_CONFIDENCE = 0.5
_SECTION_DECISIONS = {"supplement", "loose", "delete"}
_THINK_RE = re.compile(r"<think>.*?</think>", re.S)
_WRITING_ACTION_VERB_RE = re.compile(
r"(新增|增加|添加|加入|加上|补充|补上|补个|补一个|生成|撰写|写一|写个|写成|扩写|缩写|精简|改写|润色|调整|修改|重写|删除|删掉|拆成|合并|挪到|移动|规划|重新规划|设计|安排)"
)
_WRITING_ACTION_TARGET_RE = re.compile(
r"(第[一二三四五六七八九十\d]+[章节段]|章节|章|小节|段落|段|结尾|开头|标题|大纲|内容|案例|表格|总结章|总结章节|结论|摘要|观点|部分)"
)
_INFO_QUESTION_RE = re.compile(r"(哪些|什么|为什么|是什么|有哪些|多少|是否)")
def _fallback_intent() -> dict:
return {
"intent": "clarify",
"action": None,
"payload": {},
"need_research": False,
"research_query": "",
"answer": "",
"confidence": 0.0,
"clarify": "我没有理解你的意思,能换个说法再试一次吗?也可以直接点击上方卡片里的按钮操作。",
}
def _looks_like_writing_change_request(text: str) -> bool:
text = (text or "").strip()
if _INFO_QUESTION_RE.search(text):
return False
return bool(_WRITING_ACTION_VERB_RE.search(text) and _WRITING_ACTION_TARGET_RE.search(text))
def _rule_based_intent(text: str, pause_point: str | None) -> dict | None:
"""Deterministic guard for writing-operation requests that look like Q&A."""
if not _looks_like_writing_change_request(text):
return None
if pause_point == "outline_confirm":
return {
"intent": "revise_outline",
"action": "re_outline",
"payload": {"outlineFeedback": text.strip()},
"need_research": False,
"research_query": "",
"answer": "",
"confidence": 1.0,
"clarify": "",
}
if pause_point == "draft_confirm":
return {
"intent": "revise_draft",
"action": "user_revise",
"payload": {"userRevisionNotes": text.strip()},
"need_research": False,
"research_query": "",
"answer": "",
"confidence": 1.0,
"clarify": "",
}
if pause_point == "review_confirm":
return {
"intent": "accept_review_with_user_notes",
"action": "accept_review",
"payload": {"userRevisionNotes": text.strip()},
"need_research": False,
"research_query": "",
"answer": "",
"confidence": 1.0,
"clarify": "",
}
return None
# ── 上下文快照 ────────────────────────────────────────────────────────────────
@@ -362,16 +429,10 @@ async def parse_user_intent(
"""
from .nodes._utils import _content_to_text, parse_json
fallback = {
"intent": "clarify",
"action": None,
"payload": {},
"need_research": False,
"research_query": "",
"answer": "",
"confidence": 0.0,
"clarify": "我没有理解你的意思,能换个说法再试一次吗?也可以直接点击上方卡片里的按钮操作。",
}
fallback = _fallback_intent()
rule = _rule_based_intent(text, pause_point)
if rule is not None:
return normalize_intent_result(rule, pause_point, values, interrupt_value)
messages = _build_messages(text, pause_point, values, interrupt_value)
@@ -407,3 +468,106 @@ async def parse_user_intent(
parsed = parse_json(raw_text, fallback)
return normalize_intent_result(parsed, pause_point, values, interrupt_value)
async def stream_user_intent(
*,
text: str,
pause_point: str | None,
values: dict,
interrupt_value: dict | None = None,
model_name: str | None = None,
model: Any | None = None,
) -> AsyncIterator[dict]:
"""流式解析用户自由文本。
事件契约:
- ``{"type": "chunk", "chunk": "...", "is_thinking": bool}``
- ``{"type": "result", "result": normalized_intent}``
解析阶段仍以结构化 JSON 为最终产物;流式输出用于前端实时展示模型的
思考/解析过程。``<think>...</think>`` 内容和 provider reasoning 字段都会作为
``is_thinking=True`` 分流,正文 JSON 只参与最终解析。
"""
from .nodes._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta
fallback = _fallback_intent()
rule = _rule_based_intent(text, pause_point)
if rule is not None:
yield {
"type": "result",
"result": normalize_intent_result(rule, pause_point, values, interrupt_value),
}
return
messages = _build_messages(text, pause_point, values, interrupt_value)
def content_events(parser: ThinkTagParser, raw_parts: list[str], delta: str) -> list[dict]:
events = []
for is_thinking, part in parser.feed(delta):
if not is_thinking:
raw_parts.append(part)
events.append({"type": "chunk", "chunk": part, "is_thinking": is_thinking})
return events
async def run_model(m: Any) -> AsyncIterator[dict]:
parser = ThinkTagParser()
raw_parts: list[str] = []
if hasattr(m, "astream"):
async for chunk in m.astream(messages):
reasoning = reasoning_delta(chunk)
if reasoning:
yield {"type": "chunk", "chunk": reasoning, "is_thinking": True}
content = _content_to_text(getattr(chunk, "content", "") or "")
if content:
for event in content_events(parser, raw_parts, content):
yield event
else:
resp = await m.ainvoke(messages)
content = _content_to_text(getattr(resp, "content", "") or "")
if content:
for event in content_events(parser, raw_parts, content):
yield event
yield {"type": "_raw_text", "raw_text": "".join(raw_parts)}
raw_text = ""
if model is not None:
try:
async for event in run_model(model):
if event["type"] == "_raw_text":
raw_text = str(event.get("raw_text") or "")
else:
yield event
except Exception:
raw_text = ""
logger.exception("intent_router: injected stream model failed")
else:
from deerflow.models import create_chat_model
from .nodes._model_fallback import classify_exception, fallback_chain
chain = fallback_chain((model_name or "").strip() or None) or [None]
for candidate in chain:
try:
m = create_chat_model(name=candidate) if candidate else create_chat_model()
async for event in run_model(m):
if event["type"] == "_raw_text":
raw_text = str(event.get("raw_text") or "")
else:
yield event
break
except Exception as exc: # noqa: BLE001 — 换下一个模型继续
raw_text = ""
logger.warning(
"intent_router: stream model=%s failed (reason=%s): %s",
candidate, classify_exception(exc), exc,
)
continue
raw_text = _THINK_RE.sub("", raw_text or "").strip()
if not raw_text:
result = normalize_intent_result(fallback, pause_point, values, interrupt_value)
else:
parsed = parse_json(raw_text, fallback)
result = normalize_intent_result(parsed, pause_point, values, interrupt_value)
yield {"type": "result", "result": result}