Skip to content

Commit b8e9a84

Browse files
committed
feat(ai): P2-12 摘要流式输出 UI 落地(渐进显示+失败降级非流式)
1 parent 08eea36 commit b8e9a84

4 files changed

Lines changed: 115 additions & 13 deletions

File tree

‎client/src/features/notes/components/AISummaryModal.tsx‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export interface AISummaryModalProps {
2323
error: string | null | undefined;
2424
/** 错误源于缺少 API Key 配置时展示设置页引导 */
2525
needsConfig?: boolean;
26+
/** P2-12 流式:是否正在流式输出 */
27+
isStreaming?: boolean;
28+
/** P2-12 流式:逐 chunk 累积的渐进文本 */
29+
streamingText?: string;
2630
flashcardLoading: boolean;
2731
/** 已转化为闪卡的要点下标 */
2832
convertedKeys: Set<number>;
@@ -34,12 +38,14 @@ export interface AISummaryModalProps {
3438
onInsertNote: (position: 'cursor' | 'start' | 'end') => void;
3539
onRegenerate: () => void;
3640
onExport: () => void;
41+
/** P2-12 流式:取消流式输出 */
42+
onCancelStream?: () => void;
3743
}
3844

3945
export function AISummaryModal({
40-
data, loading, error, needsConfig, flashcardLoading, convertedKeys,
46+
data, loading, error, needsConfig, isStreaming, streamingText, flashcardLoading, convertedKeys,
4147
onClose, onGoSettings, onCopySummary, onGenerateFlashcard,
42-
onGenerateAllFlashcards, onInsertNote, onRegenerate, onExport,
48+
onGenerateAllFlashcards, onInsertNote, onRegenerate, onExport, onCancelStream,
4349
}: AISummaryModalProps) {
4450
const [insertMenuOpen, setInsertMenuOpen] = useState(false);
4551
const keyPointCount = data?.keyPoints?.length ?? 0;
@@ -67,7 +73,32 @@ export function AISummaryModal({
6773
AI 摘要
6874
</h2>
6975

70-
{loading && (
76+
{isStreaming && (
77+
<div className="mt-kb-md flex flex-col gap-kb-md">
78+
<div className="flex items-center gap-2 text-b3 text-text-secondary">
79+
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
80+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
81+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
82+
</svg>
83+
正在流式生成摘要…
84+
</div>
85+
<p className="text-b2 text-text-primary leading-relaxed whitespace-pre-wrap">
86+
{streamingText}
87+
<span className="inline-block w-0.5 h-4 ml-0.5 bg-brand-500 align-middle animate-pulse" />
88+
</p>
89+
{onCancelStream && (
90+
<button
91+
onClick={onCancelStream}
92+
className="self-start inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--kb-radius-md)] text-b3 font-medium text-text-tertiary hover:text-semantic-error hover:bg-semantic-error/10 transition-all duration-200"
93+
>
94+
<X className="w-3 h-3" strokeWidth={1.5} />
95+
停止生成
96+
</button>
97+
)}
98+
</div>
99+
)}
100+
101+
{loading && !isStreaming && (
71102
<div className="mt-kb-md flex items-center gap-2 text-b2 text-text-primary">
72103
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
73104
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />

‎client/src/features/notes/hooks/useNoteAI.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ export function useNoteAI(editor: Editor | null, noteId: string | null) {
3030
const [summaryModalOpen, setSummaryModalOpen] = useState(false);
3131
const [convertedKeys, setConvertedKeys] = useState<Set<number>>(new Set());
3232

33-
const { loading: aiLoading, data: aiData, error: aiError, needsConfig: aiNeedsConfig, summarize } = useAISummarize();
33+
const {
34+
loading: aiLoading, data: aiData, error: aiError, needsConfig: aiNeedsConfig,
35+
streamingText, isStreaming, summarizeStream, cancelStream,
36+
} = useAISummarize();
3437
const { loading: flashcardLoading, generate: generateFlashcards } = useAIFlashcards();
3538
const handleSummarizeError = useAIErrorHandler('AI 摘要生成失败');
3639
const handleFlashcardError = useAIErrorHandler('AI 闪卡生成失败');
@@ -64,18 +67,18 @@ export function useNoteAI(editor: Editor | null, noteId: string | null) {
6467
return result.cards.length;
6568
}, [generateFlashcards, ensureDefaultDeck, createCard, noteId]);
6669

67-
/** 顶栏入口:校验非空后生成摘要并弹出浮层 */
70+
/** 顶栏入口:校验非空后立即弹出浮层并流式生成摘要(P2-12) */
6871
const startSummarize = useCallback(() => {
6972
if (!editor) return;
7073
const text = editor.getText();
7174
if (!text.trim()) {
7275
toast({ type: 'warning', message: '请先写一些笔记内容再生成摘要' });
7376
return;
7477
}
75-
summarize(text)
76-
.then(() => setSummaryModalOpen(true))
77-
.catch(handleSummarizeError);
78-
}, [editor, summarize, toast, handleSummarizeError]);
78+
setConvertedKeys(new Set());
79+
setSummaryModalOpen(true);
80+
summarizeStream(text).catch(handleSummarizeError);
81+
}, [editor, summarizeStream, toast, handleSummarizeError]);
7982

8083
const handleInsertNote = useCallback((position: 'cursor' | 'start' | 'end') => {
8184
if (!editor || !aiData) return;
@@ -128,8 +131,8 @@ export function useNoteAI(editor: Editor | null, noteId: string | null) {
128131
const text = editor.getText();
129132
if (!text.trim()) { toast({ type: 'warning', message: '笔记内容为空' }); return; }
130133
setConvertedKeys(new Set());
131-
summarize(text).catch(handleSummarizeError);
132-
}, [editor, summarize, toast, handleSummarizeError]);
134+
summarizeStream(text).catch(handleSummarizeError);
135+
}, [editor, summarizeStream, toast, handleSummarizeError]);
133136

134137
const handleExport = useCallback(() => {
135138
if (!aiData) return;
@@ -149,6 +152,7 @@ export function useNoteAI(editor: Editor | null, noteId: string | null) {
149152
return {
150153
summaryModalOpen, setSummaryModalOpen,
151154
aiLoading, aiData, aiError, aiNeedsConfig,
155+
streamingText, isStreaming, cancelStream,
152156
flashcardLoading, convertedKeys,
153157
ensureDefaultDeck, persistCards,
154158
startSummarize, handleInsertNote, handleCopySummary,

‎client/src/features/notes/pages/NoteEditPage.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ export default function NoteEditPage() {
263263
loading={ai.aiLoading}
264264
error={ai.aiError}
265265
needsConfig={ai.aiNeedsConfig}
266+
isStreaming={ai.isStreaming}
267+
streamingText={ai.streamingText}
266268
flashcardLoading={ai.flashcardLoading}
267269
convertedKeys={ai.convertedKeys}
268270
onClose={() => ai.setSummaryModalOpen(false)}
@@ -273,6 +275,7 @@ export default function NoteEditPage() {
273275
onInsertNote={ai.handleInsertNote}
274276
onRegenerate={ai.handleRegenerate}
275277
onExport={ai.handleExport}
278+
onCancelStream={ai.cancelStream}
276279
/>
277280
)}
278281
</div>

‎client/src/lib/ai/hooks/useAISummarize.ts‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
11
/**
22
* @ai-context: Summarize 功能的 React Hook 包装:仅做加载/错误状态编排,业务调用统一走 aiPluginLoader,禁止在 Hook 内写业务计算。
3+
* @ai-context: P2-12 流式落地——summarizeStream 经 aiPluginLoader.summarizeNoteStream
4+
* 逐 chunk 累积渐进文本(streamingText),完成后从全文派生 keyPoints;
5+
* 流式失败自动降级非流式 summarize。cancelRef/streamIdRef 防竞态与卸载泄漏。
36
*/
4-
import { useState, useCallback } from 'react';
7+
import { useState, useCallback, useRef, useEffect } from 'react';
58
import { aiPluginLoader } from '../AIPluginLoader';
69
import { getLocalFallbackMessage } from '../LocalFallback';
710
import { soundPlayer } from '@/lib/audio/SoundPlayer';
811
import { type AIState, INITIAL_STATE, resolveAIErrorState } from './types';
912
import type { SummarizeResult, SummarizeOptions } from '../types';
1013

14+
/** 从摘要全文派生关键要点:切分 bullet/编号行,纯客户端增强(无则返回空数组,与原行为一致) */
15+
function deriveKeyPoints(summary: string): string[] {
16+
return summary
17+
.split('\n')
18+
.map((l) => l.trim())
19+
.filter((l) => /^([-*•·]|\d+[.、)])\s*/.test(l))
20+
.map((l) => l.replace(/^([-*•·]|\d+[.、)])\s*/, '').trim())
21+
.filter(Boolean)
22+
.slice(0, 8);
23+
}
24+
1125
/**
1226
* AI 摘要 hook
1327
*/
1428
export function useAISummarize() {
1529
const [state, setState] = useState<AIState<SummarizeResult>>({
1630
...INITIAL_STATE,
1731
});
32+
/** 流式渐进文本(逐 chunk 累积) */
33+
const [streamingText, setStreamingText] = useState('');
34+
/** 是否正在流式输出 */
35+
const [isStreaming, setIsStreaming] = useState(false);
36+
const cancelRef = useRef(false);
37+
const streamIdRef = useRef(0);
1838

1939
const summarize = useCallback(async (content: string, options?: SummarizeOptions) => {
2040
setState(prev => ({ ...prev, loading: true, error: null, needsConfig: false }));
@@ -29,5 +49,49 @@ export function useAISummarize() {
2949
}
3050
}, []);
3151

32-
return { ...state, summarize };
52+
/**
53+
* 流式摘要:逐 chunk 渐进显示,完成后派生结构化结果;失败降级非流式
54+
*/
55+
const summarizeStream = useCallback(async (content: string, options?: SummarizeOptions) => {
56+
cancelRef.current = false;
57+
const streamId = ++streamIdRef.current;
58+
setState(prev => ({ ...prev, loading: true, error: null, needsConfig: false }));
59+
setIsStreaming(true);
60+
setStreamingText('');
61+
let accumulated = '';
62+
try {
63+
const iterable = aiPluginLoader.summarizeNoteStream(content, options);
64+
for await (const chunk of iterable) {
65+
if (cancelRef.current || streamIdRef.current !== streamId) return;
66+
accumulated += chunk;
67+
setStreamingText(accumulated);
68+
}
69+
if (cancelRef.current || streamIdRef.current !== streamId) return;
70+
const result: SummarizeResult = {
71+
summary: accumulated,
72+
keyPoints: deriveKeyPoints(accumulated),
73+
generatedAt: new Date(),
74+
};
75+
soundPlayer.play('ai_analysis_done');
76+
setState({ data: result, loading: false, error: null, isFallback: false, needsConfig: false });
77+
setIsStreaming(false);
78+
return result;
79+
} catch (error: unknown) {
80+
if (cancelRef.current || streamIdRef.current !== streamId) return;
81+
// 流式失败 → 降级非流式(非流式内部自行处理错误态)
82+
setIsStreaming(false);
83+
return summarize(content, options);
84+
}
85+
}, [summarize]);
86+
87+
/** 取消当前流式输出 */
88+
const cancelStream = useCallback(() => {
89+
cancelRef.current = true;
90+
setIsStreaming(false);
91+
}, []);
92+
93+
// 组件卸载时自动取消,避免对已卸载组件 setState
94+
useEffect(() => () => { cancelRef.current = true; }, []);
95+
96+
return { ...state, streamingText, isStreaming, summarize, summarizeStream, cancelStream };
3397
}

0 commit comments

Comments
 (0)