Skip to content

Commit 5d2318c

Browse files
committed
feat(ai): AI hooks 流式化改造——苏格拉底追问/救援打字机渐进展示
- useAISocratic: 新增 askQuestionStream 流式追问,JSON 宽松解析,失败自动降级非流式 - useAIRescue: 新增 rescueStream 流式救援,打字机渐进展示,取消保护 - AIPluginLoader: 统一调用链,getAIPlugin 委托内部环境方法 - visionWorker: 收紧导出(接口/类型改为内部) - RescuePanel: 接入流式救援(rescueStream)
1 parent 2f1a644 commit 5d2318c

5 files changed

Lines changed: 210 additions & 23 deletions

File tree

‎client/src/components/RescuePanel.tsx‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,20 @@ const SUGGESTIONS = [
4242
export function RescuePanel({ isOpen, onClose, context, onSuggestion }: RescuePanelProps) {
4343
const [activeLevel, setActiveLevel] = useState<RescueLevel>(1);
4444
const [showIncubation, setShowIncubation] = useState(false);
45-
const { loading, data, error, needsConfig, rescue } = useAIRescue();
45+
const { loading, data, error, needsConfig, streamingText, rescueStream } = useAIRescue();
4646
const navigate = useNavigate();
4747

48-
// 面板打开时触发救援请求
48+
// 面板打开时触发救援请求(P5 流式:打字机渐进展示,失败自动降级非流式)
4949
useEffect(() => {
5050
if (isOpen && context.topic) {
5151
const rescueCtx: RescueContext = {
5252
topic: context.topic,
5353
relatedContent: context.relatedContent,
5454
mode: (context.mode as RescueContext['mode']) ?? 'general',
5555
};
56-
rescue(rescueCtx);
56+
rescueStream(rescueCtx);
5757
}
58-
}, [isOpen, context.topic, context.relatedContent, context.mode, rescue]);
58+
}, [isOpen, context.topic, context.relatedContent, context.mode, rescueStream]);
5959

6060
// 卡壳超10分钟显示孵化建议(由外部 useStuckTimer 触发)
6161
useEffect(() => {
@@ -146,8 +146,15 @@ export function RescuePanel({ isOpen, onClose, context, onSuggestion }: RescuePa
146146
<div className="flex-1 overflow-y-auto px-kb-md py-kb-md">
147147
{loading && (
148148
<div className="flex items-center gap-2 text-b2 text-text-secondary py-8 justify-center">
149-
<AIThinkingIndicator size={4} gap={3} />
150-
AI 正在思考救援方案…
149+
{/* P5 流式:首 chunk 到达前显示思考动画,之后打字机渐进展示 */}
150+
{streamingText ? (
151+
<p className="text-b2 text-text-secondary leading-relaxed whitespace-pre-wrap">{streamingText}</p>
152+
) : (
153+
<>
154+
<AIThinkingIndicator size={4} gap={3} />
155+
AI 正在思考救援方案…
156+
</>
157+
)}
151158
</div>
152159
)}
153160

‎client/src/lib/ai/AIPluginLoader.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
import { LocalDurationRecommender } from './LocalFallback';
1414
import { AIError } from './ai-errors';
1515
import { ensureOnline, ensureMinLength } from './aiGuards';
16-
import { getAIPlugin, getRemotePlugin, getElectronPlugin } from './aiPluginProvider';
16+
import { getRemotePlugin, getElectronPlugin } from './aiPluginProvider';
1717
import { offlineAIQueue } from './offlineAIQueue';
18+
import { isElectron } from '../utils/platform';
1819
import type { AIPlugin, DurationHistoryData, DurationOptions, DurationResult,
1920
SummarizeResult, FlashcardResult, EvaluateResult,
2021
SummarizeOptions, FlashcardOptions, EvaluateOptions,
@@ -38,8 +39,11 @@ class AIPluginLoader {
3839
/** 获取 Electron AI 插件实例(委托 aiPluginProvider,保留旧 API) */
3940
async getElectronPlugin() { return getElectronPlugin(); }
4041

41-
/** 根据运行环境获取 AI 插件实例 */
42-
async getAIPlugin(): Promise<AIPlugin> { return getAIPlugin(); }
42+
/** 根据运行环境获取 AI 插件实例(内部委托本类两个环境方法,统一调用链) */
43+
async getAIPlugin(): Promise<AIPlugin> {
44+
if (isElectron()) return await this.getElectronPlugin();
45+
return this.getRemotePlugin();
46+
}
4347

4448
/**
4549
* 统一非流式调用包装:守卫 → 取插件 → 能力检查 → 调用
@@ -62,7 +66,7 @@ class AIPluginLoader {
6266
throw err;
6367
}
6468
ensureMinLength(opts.contentCheck, opts.minLength);
65-
const plugin = await getAIPlugin();
69+
const plugin = await this.getAIPlugin();
6670
const result = invoke(plugin);
6771
if (result === undefined) {
6872
throw new AIError(opts.unsupportedMsg, 'service_unavailable', false);
@@ -79,7 +83,7 @@ class AIPluginLoader {
7983
): AsyncGenerator<string, void, unknown> {
8084
ensureOnline();
8185
ensureMinLength(opts.contentCheck, opts.minLength);
82-
const plugin = await getAIPlugin();
86+
const plugin = await this.getAIPlugin();
8387
const s = getStreamFn(plugin);
8488
if (!s) {
8589
throw new AIError(opts.unsupportedMsg, 'service_unavailable', true);
@@ -117,7 +121,7 @@ class AIPluginLoader {
117121
* 注意:刻意不做离线守卫(Electron 本地视觉可离线使用,历史行为)
118122
*/
119123
async extractScreenContent(imageBase64: string, language = 'zh'): Promise<VisionExtractResult> {
120-
const plugin = await getAIPlugin();
124+
const plugin = await this.getAIPlugin();
121125
if (plugin.extractScreenContent) {
122126
return plugin.extractScreenContent(imageBase64, language);
123127
}
@@ -128,7 +132,7 @@ class AIPluginLoader {
128132
async recommendDuration(historyData: DurationHistoryData, options?: DurationOptions): Promise<DurationResult> {
129133
ensureOnline();
130134
try {
131-
return await (await getAIPlugin()).recommendDuration(historyData, options);
135+
return await (await this.getAIPlugin()).recommendDuration(historyData, options);
132136
} catch {
133137
return this.localRecommender.recommend(historyData, options);
134138
}

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

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,56 @@
11
/**
22
* @ai-context: Rescue 功能的 React Hook 包装:仅做加载/错误状态编排,业务调用统一走 aiPluginLoader,禁止在 Hook 内写业务计算。
3+
* @ai-context: P5 流式接入——rescueStream 经 aiPluginLoader.rescueStream 逐 chunk
4+
* 累积 JSON(rescue_v1 模板强制 JSON),完成后宽松解析;失败自动降级非流式 rescue。
5+
* cancelRef/streamIdRef 防竞态与卸载泄漏(与 useAISummarize 流式模式一致)。
36
*/
4-
import { useState, useCallback } from 'react';
7+
import { useState, useCallback, useRef, useEffect } from 'react';
58
import { aiPluginLoader } from '../AIPluginLoader';
69
import { resolveAIFallback, setAICache, FallbackLevel } from '../aiServiceFallback';
710
import { type AIState, INITIAL_STATE, resolveAIErrorState } from './types';
811
import { withTimeout } from './withTimeout';
912
import type { RescueContext, ResourceLink } from '../types';
1013

14+
/** 救援结果结构(与 useAIRescue 状态一致) */
15+
export interface RescueResultData {
16+
hints: string[];
17+
resources: ResourceLink[];
18+
alternativeApproach?: string;
19+
}
20+
21+
/**
22+
* 宽松解析流式累积的救援 JSON(rescue_v1 模板强制 JSON;AI 输出可能带前后缀)
23+
*/
24+
function parseRescueJson(text: string): RescueResultData | null {
25+
const tryParse = (s: string): RescueResultData | null => {
26+
try {
27+
const parsed = JSON.parse(s) as {
28+
hints?: string[]; resources?: Array<{ title?: string; url?: string; type?: string }>;
29+
alternative_approach?: string; alternativeApproach?: string;
30+
};
31+
if (Array.isArray(parsed.hints) || parsed.alternative_approach || parsed.alternativeApproach) {
32+
return {
33+
hints: Array.isArray(parsed.hints) ? parsed.hints : [],
34+
resources: Array.isArray(parsed.resources)
35+
? parsed.resources.map((r) => ({
36+
title: r.title ?? '',
37+
url: r.url ?? '',
38+
type: (['article', 'video', 'exercise', 'documentation', 'other'] as const)
39+
.find((t) => t === r.type) ?? 'other',
40+
}))
41+
: [],
42+
alternativeApproach: parsed.alternative_approach ?? parsed.alternativeApproach,
43+
};
44+
}
45+
} catch { /* fallthrough */ }
46+
return null;
47+
};
48+
const direct = tryParse(text);
49+
if (direct) return direct;
50+
const block = text.match(/\{[\s\S]*\}/);
51+
return block ? tryParse(block[0]) : null;
52+
}
53+
1154
/**
1255
* AI 学习救援 hook
1356
*
@@ -24,6 +67,12 @@ export function useAIRescue() {
2467
}>>({
2568
...INITIAL_STATE,
2669
});
70+
/** 流式渐进文本(逐 chunk 累积,打字机展示) */
71+
const [streamingText, setStreamingText] = useState('');
72+
/** 是否正在流式输出 */
73+
const [isStreaming, setIsStreaming] = useState(false);
74+
const cancelRef = useRef(false);
75+
const streamIdRef = useRef(0);
2776

2877
const rescue = useCallback(async (context: RescueContext) => {
2978
setState(prev => ({ ...prev, loading: true, error: null, needsConfig: false }));
@@ -47,5 +96,49 @@ export function useAIRescue() {
4796
}
4897
}, []);
4998

50-
return { ...state, rescue };
99+
/**
100+
* P5 流式学习救援:逐 chunk 累积 JSON,完成后解析结构化结果;失败降级非流式 rescue
101+
*/
102+
const rescueStream = useCallback(async (context: RescueContext) => {
103+
cancelRef.current = false;
104+
const streamId = ++streamIdRef.current;
105+
setState(prev => ({ ...prev, loading: true, error: null, needsConfig: false }));
106+
setIsStreaming(true);
107+
setStreamingText('');
108+
let accumulated = '';
109+
try {
110+
const iterable = aiPluginLoader.rescueStream(context);
111+
for await (const chunk of iterable) {
112+
if (cancelRef.current || streamIdRef.current !== streamId) return null;
113+
accumulated += chunk;
114+
setStreamingText(accumulated);
115+
}
116+
if (cancelRef.current || streamIdRef.current !== streamId) return null;
117+
const parsed = parseRescueJson(accumulated);
118+
if (!parsed) {
119+
throw new Error('AI 流式返回无法解析为救援 JSON');
120+
}
121+
const cacheKey = `rescue:${context.topic.slice(0, 100)}:${context.stuckPoint?.slice(0, 50) || 'default'}`;
122+
setAICache(cacheKey, parsed);
123+
setState({ data: parsed, loading: false, error: null, isFallback: false, needsConfig: false });
124+
setIsStreaming(false);
125+
return parsed;
126+
} catch (error: unknown) {
127+
if (cancelRef.current || streamIdRef.current !== streamId) return null;
128+
// 流式失败 → 降级非流式(非流式内部自行处理错误态与降级链)
129+
setIsStreaming(false);
130+
return rescue(context);
131+
}
132+
}, [rescue]);
133+
134+
/** 取消当前流式输出 */
135+
const cancelStream = useCallback(() => {
136+
cancelRef.current = true;
137+
setIsStreaming(false);
138+
}, []);
139+
140+
// 组件卸载时自动取消,避免对已卸载组件 setState
141+
useEffect(() => () => { cancelRef.current = true; }, []);
142+
143+
return { ...state, streamingText, isStreaming, rescue, rescueStream, cancelStream };
51144
}

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

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @ai-context: Socratic 功能的 React Hook 包装:仅做加载/错误状态编排,业务调用统一走 aiPluginLoader,禁止在 Hook 内写业务计算。
33
*/
4-
import { useState, useCallback } from 'react';
4+
import { useState, useCallback, useRef, useEffect } from 'react';
55
import { aiPluginLoader } from '../AIPluginLoader';
66
import { soundPlayer } from '@/lib/audio/SoundPlayer';
77
import {
@@ -15,6 +15,26 @@ import {
1515
import { type AIState, INITIAL_STATE, resolveAIErrorState } from './types';
1616
import type { BrainstormIdea, ChatMessage, SocraticEvaluateResult, SocraticDeepeningResult, SocraticMirrorResult } from '../types';
1717

18+
/**
19+
* 宽松解析流式累积的苏格拉底追问 JSON(socratic_v1 模板强制 JSON:
20+
* {"question": "...", "hints": [...]};AI 输出可能带前后缀说明文字)
21+
*/
22+
function parseSocraticQuestionJson(text: string): { question: string; hints: string[] } | null {
23+
const tryParse = (s: string): { question: string; hints: string[] } | null => {
24+
try {
25+
const parsed = JSON.parse(s) as { question?: string; hints?: string[] };
26+
if (typeof parsed.question === 'string' && parsed.question.trim()) {
27+
return { question: parsed.question, hints: Array.isArray(parsed.hints) ? parsed.hints : [] };
28+
}
29+
} catch { /* fallthrough */ }
30+
return null;
31+
};
32+
const direct = tryParse(text);
33+
if (direct) return direct;
34+
const block = text.match(/\{[\s\S]*\}/);
35+
return block ? tryParse(block[0]) : null;
36+
}
37+
1838
/**
1939
* AI 苏格拉底式学习 hook
2040
*
@@ -47,6 +67,12 @@ export function useAISocratic() {
4767
const [mirrorState, setMirrorState] = useState<AIState<SocraticMirrorResult>>({
4868
...INITIAL_STATE,
4969
});
70+
/** 追问流式渐进文本(逐 chunk 累积,打字机展示) */
71+
const [streamingQuestion, setStreamingQuestion] = useState('');
72+
/** 是否正在流式追问输出 */
73+
const [isQuestionStreaming, setIsQuestionStreaming] = useState(false);
74+
const questionCancelRef = useRef(false);
75+
const questionStreamIdRef = useRef(0);
5076

5177
/** 苏格拉底式头脑风暴 — 带 300 秒缓存 + 请求去重 + 降级处理 */
5278
const brainstorm = useCallback(async (topic: string, context?: string) => {
@@ -106,7 +132,6 @@ export function useAISocratic() {
106132
const askQuestion = useCallback(async (conversationId: string, topic: string, history: ChatMessage[]) => {
107133
setQuestionState(prev => ({ ...prev, loading: true, error: null, needsConfig: false }));
108134
const cacheKey = `socratic_question:${conversationId}:${topic.slice(0, 100)}`;
109-
110135
try {
111136
const result = await aiPluginLoader.socraticQuestion(conversationId, topic, history);
112137

@@ -141,6 +166,57 @@ export function useAISocratic() {
141166
}
142167
}, []);
143168

169+
/**
170+
* A 组流式接入:苏格拉底追问打字机(/socratic/stream SSE)
171+
* 逐 chunk 累积 JSON(socratic_v1 模板强制 {"question","hints"}),完成后宽松解析;
172+
* 流式失败自动降级非流式 askQuestion。onChunk 回调供 UI 渐进展示。
173+
*/
174+
const askQuestionStream = useCallback(async (
175+
conversationId: string,
176+
topic: string,
177+
history: ChatMessage[],
178+
onChunk?: (partial: string) => void,
179+
) => {
180+
questionCancelRef.current = false;
181+
const streamId = ++questionStreamIdRef.current;
182+
setQuestionState(prev => ({ ...prev, loading: true, error: null, needsConfig: false }));
183+
setIsQuestionStreaming(true);
184+
setStreamingQuestion('');
185+
let accumulated = '';
186+
try {
187+
const iterable = aiPluginLoader.socraticQuestionStream(conversationId, topic, history);
188+
for await (const chunk of iterable) {
189+
if (questionCancelRef.current || questionStreamIdRef.current !== streamId) return null;
190+
accumulated += chunk;
191+
setStreamingQuestion(accumulated);
192+
onChunk?.(accumulated);
193+
}
194+
if (questionCancelRef.current || questionStreamIdRef.current !== streamId) return null;
195+
const parsed = parseSocraticQuestionJson(accumulated);
196+
if (!parsed) {
197+
throw new Error('AI 流式返回无法解析为苏格拉底追问 JSON');
198+
}
199+
soundPlayer.play('ai_analysis_done');
200+
setQuestionState({ data: parsed, loading: false, error: null, isFallback: false, needsConfig: false });
201+
setIsQuestionStreaming(false);
202+
return parsed;
203+
} catch (error: unknown) {
204+
if (questionCancelRef.current || questionStreamIdRef.current !== streamId) return null;
205+
// 流式失败 → 降级非流式(非流式内部自行处理错误态与降级链)
206+
setIsQuestionStreaming(false);
207+
return askQuestion(conversationId, topic, history);
208+
}
209+
}, [askQuestion]);
210+
211+
/** 取消当前流式追问 */
212+
const cancelQuestionStream = useCallback(() => {
213+
questionCancelRef.current = true;
214+
setIsQuestionStreaming(false);
215+
}, []);
216+
217+
// 组件卸载时自动取消,避免对已卸载组件 setState
218+
useEffect(() => () => { questionCancelRef.current = true; }, []);
219+
144220
/** FEAT-022: 苏格拉底回答评估 — 带 300 秒缓存 + 请求去重 + 降级处理 */
145221
const evaluateAnswer = useCallback(async (topic: string, question: string, answer: string, history: ChatMessage[]) => {
146222
// 评估缓存 key 包含 topic+question+answer 的摘要
@@ -304,7 +380,14 @@ export function useAISocratic() {
304380

305381
return {
306382
brainstorm: { ...brainstormState, brainstorm },
307-
question: { ...questionState, askQuestion },
383+
question: {
384+
...questionState,
385+
askQuestion,
386+
askQuestionStream,
387+
cancelQuestionStream,
388+
streamingQuestion,
389+
isQuestionStreaming,
390+
},
308391
evaluate: { ...evaluateState, evaluateAnswer },
309392
deepening: { ...deepeningState, generateDeepeningAngles },
310393
mirror: { ...mirrorState, mirrorQuestion },

‎client/src/lib/ai/visionWorker.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@ import { supabase } from '@/lib/auth/supabaseClient';
2020
// ================================================================
2121

2222
/** 视觉提取模式 */
23-
export type VisionExtractMode = 'auto' | 'text' | 'formula' | 'diagram' | 'code' | 'full';
23+
type VisionExtractMode = 'auto' | 'text' | 'formula' | 'diagram' | 'code' | 'full';
2424

2525
/** 代码块结构 */
26-
export interface CodeBlock {
26+
interface CodeBlock {
2727
language: string;
2828
code: string;
2929
}
3030

3131
/** 视觉提取选项 */
32-
export interface VisionExtractOptions {
32+
interface VisionExtractOptions {
3333
/** 提取模式,默认 'auto' */
3434
mode?: VisionExtractMode;
3535
}
@@ -196,7 +196,7 @@ export class VisionWorker implements PipelineWorker {
196196
// ================================================================
197197

198198
/** 视觉提取错误类型 */
199-
export type VisionErrorType =
199+
type VisionErrorType =
200200
| 'network' // 网络不可达 / fetch 失败
201201
| 'timeout' // 请求超时
202202
| 'auth' // 认证失败 401/403
@@ -205,7 +205,7 @@ export type VisionErrorType =
205205
| 'unknown'; // 未分类
206206

207207
/** 分类后的错误信息 */
208-
export interface VisionErrorInfo {
208+
interface VisionErrorInfo {
209209
type: VisionErrorType;
210210
message: string;
211211
statusCode?: number;

0 commit comments

Comments
 (0)