fix: improve ai writing chat streaming and intent handling
This commit is contained in:
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
|
||||
|
||||
+175
-11
@@ -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}
|
||||
Reference in new issue
Block a user