11/**
22 * @ai -context: Socratic 功能的 React Hook 包装:仅做加载/错误状态编排,业务调用统一走 aiPluginLoader,禁止在 Hook 内写业务计算。
33 */
4- import { useState , useCallback } from 'react' ;
4+ import { useState , useCallback , useRef , useEffect } from 'react' ;
55import { aiPluginLoader } from '../AIPluginLoader' ;
66import { soundPlayer } from '@/lib/audio/SoundPlayer' ;
77import {
@@ -15,6 +15,26 @@ import {
1515import { type AIState , INITIAL_STATE , resolveAIErrorState } from './types' ;
1616import 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 } ,
0 commit comments