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 && (