88 * @ai -context: 降级可见:任务失败按原因四类展示引导(未授权→设置密钥/授权、
99 * 网络→重试、余额→充值、配额→明日再试),本地规则版保留。
1010 */
11- import { useCallback , useEffect , useRef , useState } from "react" ;
11+ import { useCallback , useEffect , useState } from "react" ;
1212import { invoke } from "@tauri-apps/api/core" ;
13- import { listen } from "@tauri-apps/api/event" ;
13+ // M8:轮询/事件双通道/卡住检测抽入共用 hook(与 EnrichPanel 同源)
14+ import { useAiTaskPolling } from "../hooks/useAiTaskPolling" ;
15+ import { failureGuide } from "./aiTaskFailure" ;
16+ import type { AiTaskFailureLike } from "./aiTaskFailure" ;
1417import type {
1518 AiRefineResult ,
1619 AiSettingsView ,
@@ -22,20 +25,6 @@ import type {
2225
2326const btn : React . CSSProperties = { padding : "5px 10px" , cursor : "pointer" , fontSize : 12 , borderRadius : 6 } ;
2427
25- /** 任务失败原因 → 引导文案(REQ-145 四类出口) */
26- function failureGuide ( failure : AiTaskFailureLike ) : string {
27- const [ kind , msg ] = Object . entries ( failure ) [ 0 ] ?? [ "other" , "未知错误" ] ;
28- switch ( kind ) {
29- case "unauthorized" : return `未授权:${ msg } (请到设置页配置密钥并开启 AI 功能)` ;
30- case "network" : return `网络错误:${ msg } (可重试)` ;
31- case "balance" : return `余额不足:${ msg } (请充值或切换免费档模型)` ;
32- case "quota" : return `配额受限:${ msg } (请明日再试)` ;
33- case "invalid" : return `响应非法已丢弃:${ msg } (本地规则版保留)` ;
34- default : return msg ;
35- }
36- }
37- type AiTaskFailureLike = Record < string , string > ;
38-
3928/** diff 三态渲染(本地版为基线:删除红划线/新增绿底/未变灰) */
4029function DiffLine ( { op } : { op : DiffOp } ) {
4130 if ( "added" in op ) {
@@ -57,55 +46,23 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
5746 const [ failure , setFailure ] = useState < AiTaskFailureLike | null > ( null ) ;
5847 const [ remember , setRemember ] = useState ( false ) ;
5948 const [ msg , setMsg ] = useState ( "" ) ;
60- const polling = useRef < ReturnType < typeof setInterval > | null > ( null ) ;
61- // 审查修复(2026-08-21 真机 debug):taskId 与 handleState 用 ref 持有——
62- // 事件/轮询回调在任务期间多次触发,若闭包捕获旧渲染版本(taskId=null),
63- // 任务成功时 ai_refine_result 以 null 查询会失败 → 永久卡"排队中"
6449 const [ , setTaskId ] = useState < number | null > ( null ) ;
65- const taskIdRef = useRef < number | null > ( null ) ;
66- const handleStateRef = useRef < ( st : AiTaskState ) => Promise < void > > ( async ( ) => { } ) ;
67- // 卡住检测(2026-08-21 真机"排队中"排查):任务 30s 仍 Pending → 提示查看日志
68- const lastChangeRef = useRef ( 0 ) ;
69-
70- // 事件通道(ai:task-update)——与轮询双通道,事件优先即时;订阅一次,
71- // 回调经 handleStateRef 取最新实现(无闭包过期)
72- useEffect ( ( ) => {
73- const un = listen < [ number , AiTaskState ] > ( "ai:task-update" , ( e ) => {
74- const [ tid , st ] = e . payload ;
75- if ( tid !== taskIdRef . current ) return ;
76- void handleStateRef . current ( st ) ;
77- } ) ;
78- return ( ) => {
79- un . then ( ( f ) => f ( ) ) ;
80- } ;
81- } , [ ] ) ;
82-
83- // 审查修复(2026-08-21):组件卸载时停止轮询——否则 interval 持续 invoke
84- // 并对已卸载组件 setState(切会话/关面板后泄漏)
85- useEffect ( ( ) => {
86- return ( ) => {
87- if ( polling . current ) {
88- clearInterval ( polling . current ) ;
89- polling . current = null ;
90- }
91- } ;
92- } , [ ] ) ;
9350
9451 useEffect ( ( ) => {
9552 void invoke < AiSettingsView > ( "ai_get_settings" ) . then ( setSettings ) . catch ( ( ) => undefined ) ;
9653 } , [ ] ) ;
9754
98- const handleState = useCallback ( async ( st : AiTaskState ) => {
99- const tid = taskIdRef . current ;
55+ // M8:任务状态派发(纯业务)——轮询/事件/卡住检测/卸载清理均由
56+ // useAiTaskPolling 承担;taskId 以参数传入(原实现靠 ref 镜像防闭包过期,
57+ // 现由 hook 内部 ref 统一保障)
58+ const handleState = useCallback ( async ( st : AiTaskState , tid : number | null ) => {
10059 if ( st === "Succeeded" ) {
101- stopPolling ( ) ;
10260 const r = await invoke < AiRefineResult > ( "ai_refine_result" , { taskId : tid } ) . catch ( ( ) => null ) ;
10361 if ( r ) {
10462 setResult ( r ) ;
10563 setPhase ( "done" ) ;
10664 }
10765 } else if ( typeof st === "object" && st !== null && "Failed" in st ) {
108- stopPolling ( ) ;
10966 setFailure ( st . Failed . reason as AiTaskFailureLike ) ;
11067 setPhase ( "failed" ) ;
11168 } else if ( typeof st === "object" && st !== null && "Running" in st ) {
@@ -114,38 +71,12 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
11471 }
11572 } , [ ] ) ;
11673
117- // 同步最新 handleState 到 ref(事件/轮询回调总用最新实现)
118- useEffect ( ( ) => {
119- handleStateRef . current = handleState ;
120- } , [ handleState ] ) ;
121-
122- const stopPolling = ( ) => {
123- if ( polling . current ) {
124- clearInterval ( polling . current ) ;
125- polling . current = null ;
126- }
127- } ;
128-
129- const poll = ( id : number ) => {
130- stopPolling ( ) ;
131- lastChangeRef . current = Date . now ( ) ;
132- polling . current = setInterval ( async ( ) => {
133- const st = await invoke < AiTaskState > ( "ai_refine_status" , { taskId : id } ) . catch ( ( ) => null ) ;
134- if ( st ) {
135- // 状态有进展(Running/Succeeded/Failed)→ 刷新时间戳;仅 Pending 计入卡住窗口
136- if ( st !== "Pending" ) lastChangeRef . current = Date . now ( ) ;
137- void handleStateRef . current ( st ) ;
138- }
139- // 卡住检测:长时间仍 Pending = 任务未启动或后台卡死(tauri 终端看 [refine-task] 日志)
140- if ( Date . now ( ) - lastChangeRef . current > 30_000 ) {
141- stopPolling ( ) ;
142- taskIdRef . current = null ; // 隔离旧任务后续事件
143- setTaskId ( null ) ;
144- setPhase ( "idle" ) ;
145- setMsg ( "任务 30 秒无进展(可能未启动或后台卡住)——请查看 tauri 终端 [refine-task] 日志后重试" ) ;
146- }
147- } , 1500 ) ;
148- } ;
74+ const { taskIdRef, startPolling, stopPolling } = useAiTaskPolling ( handleState , ( ) => {
75+ // 30s 卡住 UI 复位(文案与抽取前一致)
76+ setTaskId ( null ) ;
77+ setPhase ( "idle" ) ;
78+ setMsg ( "任务 30 秒无进展(可能未启动或后台卡住)——请查看 tauri 终端 [refine-task] 日志后重试" ) ;
79+ } ) ;
14980
15081 /** ① 预估 + 余额(确认弹窗数据源) */
15182 const prepare = async ( ) => {
@@ -205,8 +136,8 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
205136 if ( handle ) {
206137 taskIdRef . current = handle . taskId ;
207138 setTaskId ( handle . taskId ) ;
208- void handleState ( handle . state ) ;
209- poll ( handle . taskId ) ;
139+ void handleState ( handle . state , handle . taskId ) ;
140+ startPolling ( handle . taskId ) ;
210141 }
211142 } ;
212143
@@ -330,7 +261,7 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
330261 { /* 失败(降级可见 + 原因引导 + 重试) */ }
331262 { phase === "failed" && failure && (
332263 < div style = { { fontSize : 12 , color : "#b91c1c" , marginBottom : 6 } } >
333- ❌ { failureGuide ( failure ) }
264+ ❌ { failureGuide ( failure , "本地规则版保留" ) }
334265 < div style = { { marginTop : 6 } } >
335266 < button style = { { ...btn , border : "1px solid #d1d5db" } } onClick = { reset } > 重试</ button >
336267 < span style = { { marginLeft : 8 , color : "#6b7280" } } > 本地规则版保留(不丢不假)</ span >
0 commit comments