11import 'dart:async' ;
22import 'dart:convert' ;
3+ import 'package:http/http.dart' as http;
4+ import 'dart:io' ;
35import 'package:dio/dio.dart' ;
6+ import 'package:dio/io.dart' ;
7+ import 'package:we_pei_yang_flutter/commons/preferences/common_prefs.dart' ;
48import 'dart:math' ;
59import 'xiaotian_model.dart' ;
610
11+
712const XIAOTIAN_URL = 'https://student.tju.edu.cn/ai' ;
813
914/// API 单例
@@ -21,131 +26,195 @@ class AiTjuApi {
2126 ),
2227 );
2328
24- // 2. 添加 LogInterceptor 拦截器
25- void setupDio () {
26- dio.interceptors.add (
27- LogInterceptor (
28- requestHeader: true , // 是否打印请求头
29- requestBody: true , // 是否打印请求体
30- responseHeader: true , // 是否打印响应头
31- responseBody: false , // 是否打印响应体
32- error: true , // 是否打印错误信息
33- logPrint: (obj) => print (obj.toString ()), // 使用 print 函数来输出日志
34- ),
35- );
36- }
37-
38- /// Cho phép cập nhật header mặc định (Cookie/Authorization...) nếu muốn
39- void updateDefaultHeaders (Map <String , String > headers) {
40- dio.options.headers.addAll (headers);
41- }
42-
43- /* SSE 流式对话 */
4429 Stream <ChatEvent > streamChat ({
4530 required String prompt,
4631 required String sessionId,
47- required String userId,
48- List <String >? files,
4932 String ? searchTime,
5033 String ? searchType,
51- Map <String , String >? headers, // header
52- }) async * {
53- // 1. 创建并启动计时器
54- final stopwatch = Stopwatch ()..start ();
55- bool firstLineReceived = false ;
56- bool firstDataEventYielded = false ;
57-
58- print (" T0: [${stopwatch .elapsedMilliseconds }ms] 开始执行 streamChat 方法..." );
34+ Map <String , String >? headers,
35+ }) {
36+ final streamController = StreamController <ChatEvent >();
5937
60- final params = < String , dynamic > {
38+ final params = {
6139 'prompt' : prompt,
62- 'sessionId' : sessionId,
63- 'userId' : userId,
64- if (files != null ) 'files' : files,
65- if (searchTime != null ) 'searchTime' : searchTime,
66- if (searchType != null ) 'searchType' : searchType,
40+ 'session_id' : sessionId,
41+ 'user_id' : CommonPreferences .userNumber.value, //获取账号学号
42+ 'searchTime' : searchTime ?? 'noLimit' ,
43+ 'searchType' : searchType ?? 'no' ,
6744 };
6845
69- try {
70- final rs = await dio.get (
71- '/ai-api/ai/stream' ,
72- queryParameters: params,
73- options: Options (
74- responseType: ResponseType .stream,
75- headers: {
76- 'Accept' : 'text/event-stream' ,
77- ...? headers,
78- },
79- ),
80- );
81-
82- // 2. 记录 dio.get 完成的时间点
83- // 这代表服务器已响应,HTTP头部已收到,准备开始接收数据流
84- print (" T1: [${stopwatch .elapsedMilliseconds }ms] Dio请求成功,收到服务器响应头(Headers)。" );
46+ final url = Uri .https ('student.tju.edu.cn' , '/ai-rag/api/chat/stream' );
47+ var request = http.Request ("POST" , url)
48+ ..bodyFields = params
49+ ..headers.addAll ({
50+ "Authorization" : CommonPreferences .token.value, //获取账号token
51+ "Accept" : "text/event-stream" ,
52+ 'Content-Type' : 'application/x-www-form-urlencoded' ,
53+ ...? headers,
54+ });
8555
86- final lines = rs.data.stream
87- .cast <List <int >>()
88- .transform (utf8.decoder)
89- .transform (const LineSplitter ());
56+ http.Client ().send (request).then ((response) {
57+ final stream = response.stream.transform (utf8.decoder);
58+ bool firstDataEventYielded = false ;
9059
91- await for (final line in lines) {
92- // 3. 记录收到第一行数据的时间点
93- if (! firstLineReceived) {
94- firstLineReceived = true ;
95- print (" T2: [${stopwatch .elapsedMilliseconds }ms] 收到第一行流数据。" );
96- }
97-
98- if (! line.startsWith ('data:' )) {
99- continue ;
100- }
60+ stream.listen (
61+ (data) {
62+ final dataLines = data.split ("\n " ).where ((element) => element.trim ().isNotEmpty).toList ();
63+ for (String line in dataLines) {
64+ line = line.trim ();
65+ if (line.startsWith ('event:' )) continue ;
66+ if (! line.startsWith ('data:' )) continue ;
10167
102- final payload = line.substring (5 ).trimLeft ();
103- if (payload.isEmpty) continue ;
104- if (payload == '[DONE]' ) break ;
68+ final payload = line.substring (5 ).trimLeft ();
69+ if (payload.isEmpty || payload == '[DONE]' ) continue ;
10570
106- try {
107- final map = jsonDecode (payload);
71+ try {
72+ final map = jsonDecode (payload);
73+ if (! firstDataEventYielded && map.keys.any ((k) => ['token' , 'sources' , 'question' , 'trace_id' , 'error' ].contains (k))) {
74+ firstDataEventYielded = true ;
75+ }
10876
109- // 4. 记录解析并准备推送第一个有效事件的时间点
110- if (! firstDataEventYielded) {
111- // 确保这是一个有内容的事件,而不是空的 keep-alive 包
112- if (map.keys.any ((k) => ['token' , 'sources' , 'question' , 'trace_id' , 'error' ].contains (k))) {
113- firstDataEventYielded = true ;
114- print (" T3: [${stopwatch .elapsedMilliseconds }ms] 解析并产出(yield)第一个有效数据事件。" );
77+ if (map['token' ] != null ) streamController.add (ChatEvent .token (map['token' ]));
78+ if (map['question' ] != null ) streamController.add (ChatEvent .followup (map['question' ]));
79+ if (map['sources' ] != null ) {
80+ final list = (map['sources' ] as List ).map ((e) => Source .fromJson (e as Map <String , dynamic >)).toList ();
81+ streamController.add (ChatEvent .source (list));
82+ }
83+ if (map['trace_id' ] != null ) streamController.add (ChatEvent .traceId (map['trace_id' ].toString ()));
84+ if (map['error' ] != null ) streamController.add (ChatEvent .error (map['error' ].toString ()));
85+ } catch (e) {
86+ // Ignore json parsing errors for incomplete data chunks
11587 }
11688 }
117-
118- // --- 原有逻辑 ---
119- if (map['token' ] != null ) yield ChatEvent .token (map['token' ]);
120- if (map['sources' ] != null ) {
121- final list = (map['sources' ] as List )
122- .map ((e) => Source .fromJson (e as Map <String , dynamic >))
123- .toList ();
124- yield ChatEvent .source (list);
125- }
126- if (map['question' ] != null ) {
127- yield ChatEvent .followup (map['question' ].toString ());
128- }
129- if (map['trace_id' ] != null ) {
130- yield ChatEvent .traceId (map['trace_id' ].toString ());
131- }
132- if (map['error' ] != null ) {
133- yield ChatEvent .error (map['error' ].toString ());
89+ },
90+ onDone: () {
91+ if (! streamController.isClosed) streamController.close ();
92+ },
93+ onError: (e, st) {
94+ if (! streamController.isClosed) {
95+ streamController.add (ChatEvent .error ('Stream failed: $e ' ));
96+ streamController.close ();
13497 }
135- } catch (e, st) {
136- // print("解析失败: $e\n$st");
137- }
98+ },
99+ cancelOnError: true ,
100+ );
101+ }).catchError ((e, st) {
102+ if (! streamController.isClosed) {
103+ streamController.add (ChatEvent .error ('Failed to send request: $e ' ));
104+ streamController.close ();
138105 }
139- } on DioException catch (e) {
140- print ("Dio Error at [${stopwatch .elapsedMilliseconds }ms]: $e " );
141- // 重新抛出异常,让上层能捕获
142- rethrow ;
143- } finally {
144- stopwatch.stop ();
145- print (" T_End: [${stopwatch .elapsedMilliseconds }ms] streamChat 方法执行完毕。" );
146- }
106+ });
107+
108+ return streamController.stream;
147109 }
148110
111+
112+ /// Cho phép cập nhật header mặc định (Cookie/Authorization...) nếu muốn
113+ // void updateDefaultHeaders(Map<String, String> headers) {
114+ // dio.options.headers.addAll(headers);
115+ // }
116+ //
117+ // Stream<ChatEvent> streamChat({
118+ // required String prompt,
119+ // required String sessionId,
120+ // required String userId,
121+ // List<String>? files,
122+ // String? searchTime,
123+ // String? searchType,
124+ // Map<String, String>? headers, // header
125+ // }) async* {
126+ // // 1. 创建并启动计时器
127+ // final stopwatch = Stopwatch()..start();
128+ // bool firstLineReceived = false;
129+ // bool firstDataEventYielded = false;
130+ //
131+ // print(" T0: [${stopwatch.elapsedMilliseconds}ms] 开始执行 streamChat 方法...");
132+ //
133+ // final params = <String, dynamic>{
134+ // 'prompt': prompt,
135+ // 'sessionId': sessionId,
136+ // 'userId': userId,
137+ // if (files != null) 'files': files,
138+ // if (searchTime != null) 'searchTime': searchTime,
139+ // if (searchType != null) 'searchType': searchType,
140+ // };
141+ //
142+ // try {
143+ // final rs = await dio.get(
144+ // '/ai-api/ai/stream',
145+ // queryParameters: params,
146+ // options: Options(
147+ // responseType: ResponseType.stream,
148+ // headers: {
149+ // 'Accept': 'text/event-stream',
150+ // ...?headers,
151+ // },
152+ // ),
153+ // );
154+ //
155+ //
156+ // final lines = rs.data.stream
157+ // .cast<List<int>>()
158+ // .transform(utf8.decoder)
159+ // .transform(const LineSplitter());
160+ //
161+ // await for (final line in lines) {
162+ // // 3. 记录收到第一行数据的时间点
163+ // if (!firstLineReceived) {
164+ // firstLineReceived = true;
165+ // }
166+ //
167+ // if (!line.startsWith('data:')) {
168+ // continue;
169+ // }
170+ //
171+ // final payload = line.substring(5).trimLeft();
172+ // if (payload.isEmpty) continue;
173+ // if (payload == '[DONE]') break;
174+ //
175+ // try {
176+ // final map = jsonDecode(payload);
177+ //
178+ // // 4. 记录解析并准备推送第一个有效事件的时间点
179+ // if (!firstDataEventYielded) {
180+ // // 确保这是一个有内容的事件,而不是空的 keep-alive 包
181+ // if (map.keys.any((k) => ['token', 'sources', 'question', 'trace_id', 'error'].contains(k))) {
182+ // firstDataEventYielded = true;
183+ // }
184+ // }
185+ //
186+ // // --- 原有逻辑 ---
187+ // if (map['token'] != null) yield ChatEvent.token(map['token']);
188+ // if (map['sources'] != null) {
189+ // final list = (map['sources'] as List)
190+ // .map((e) => Source.fromJson(e as Map<String, dynamic>))
191+ // .toList();
192+ // yield ChatEvent.source(list);
193+ // }
194+ // if (map['question'] != null) {
195+ // yield ChatEvent.followup(map['question'].toString());
196+ // }
197+ // if (map['trace_id'] != null) {
198+ // yield ChatEvent.traceId(map['trace_id'].toString());
199+ // }
200+ // if (map['error'] != null) {
201+ // yield ChatEvent.error(map['error'].toString());
202+ // }
203+ // } catch (e, st) {
204+ // // print("解析失败: $e\n$st");
205+ // }
206+ // }
207+ // } on DioException catch(e) {
208+ // print("Dio Error at [${stopwatch.elapsedMilliseconds}ms]: $e");
209+ // // 重新抛出异常,让上层能捕获
210+ // rethrow;
211+ // } finally {
212+ // stopwatch.stop();
213+ // print(" T_End: [${stopwatch.elapsedMilliseconds}ms] streamChat 方法执行完毕。");
214+ // }
215+ // }
216+
217+
149218 /// ============== 1b. Fetch full Answer 链接token==============
150219 /// Return fullText (token) + rawSse (所有 SSE )
151220 Future <({String fullText, String rawSse})> fetchFullAnswer ({
0 commit comments