feat: AG-UI 协议对齐与路由导航功能
- 前端: 添加 SSE 流式支持、stateSnapshot 事件、路由导航工具 - 前端: 实现工具调用审批流程,支持 pending 状态展示 - 后端: Agent 状态管理与会话持久化相关重构 - 文档: 新增 agent-agui-full-alignance 设计文档 - 测试: 补充相关单元测试和集成测试
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'api_interceptor.dart';
|
||||
@@ -92,4 +93,30 @@ class ApiClient implements IApiClient {
|
||||
throw ApiException.fromDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get<ResponseBody>(
|
||||
path,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: headers,
|
||||
),
|
||||
);
|
||||
final responseBody = response.data;
|
||||
if (responseBody == null) {
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
return responseBody.stream
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.fromDioError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,8 @@ abstract class IApiClient {
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Options? options});
|
||||
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options});
|
||||
Future<Response<T>> delete<T>(String path, {dynamic data, Options? options});
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'i_api_client.dart';
|
||||
|
||||
typedef MockHandler = dynamic Function(dynamic data);
|
||||
class MockRequest {
|
||||
final String path;
|
||||
final String method;
|
||||
final dynamic data;
|
||||
final Options? options;
|
||||
final Map<String, String>? headers;
|
||||
|
||||
MockRequest({
|
||||
required this.path,
|
||||
required this.method,
|
||||
this.data,
|
||||
this.options,
|
||||
this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
typedef MockHandler = dynamic Function(MockRequest request);
|
||||
|
||||
class _PatternRoute {
|
||||
final RegExp pattern;
|
||||
final String method;
|
||||
final MockHandler handler;
|
||||
|
||||
_PatternRoute({
|
||||
required this.pattern,
|
||||
required this.method,
|
||||
required this.handler,
|
||||
});
|
||||
}
|
||||
|
||||
class MockApiClient implements IApiClient {
|
||||
final Map<String, MockHandler> _handlers = {};
|
||||
final List<_PatternRoute> _patternHandlers = [];
|
||||
|
||||
void registerHandler(String path, String method, MockHandler handler) {
|
||||
final key = '$path:$method';
|
||||
_handlers[key] = handler;
|
||||
}
|
||||
|
||||
void registerPatternHandler(RegExp pattern, String method, MockHandler handler) {
|
||||
_patternHandlers.add(
|
||||
_PatternRoute(
|
||||
pattern: pattern,
|
||||
method: method.toUpperCase(),
|
||||
handler: handler,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void clearMocks() {
|
||||
_handlers.clear();
|
||||
_patternHandlers.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -47,6 +87,54 @@ class MockApiClient implements IApiClient {
|
||||
return _handleRequest('DELETE', path, data: data, options: options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
final key = '$path:SSE';
|
||||
final direct = _handlers[key];
|
||||
if (direct != null) {
|
||||
final response = direct(
|
||||
MockRequest(
|
||||
path: path,
|
||||
method: 'SSE',
|
||||
headers: headers,
|
||||
),
|
||||
);
|
||||
if (response is Stream<String>) {
|
||||
return response;
|
||||
}
|
||||
if (response is Iterable<String>) {
|
||||
return Stream<String>.fromIterable(response);
|
||||
}
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
for (final route in _patternHandlers) {
|
||||
if (route.method != 'SSE') {
|
||||
continue;
|
||||
}
|
||||
if (!route.pattern.hasMatch(path)) {
|
||||
continue;
|
||||
}
|
||||
final response = route.handler(
|
||||
MockRequest(
|
||||
path: path,
|
||||
method: 'SSE',
|
||||
headers: headers,
|
||||
),
|
||||
);
|
||||
if (response is Stream<String>) {
|
||||
return response;
|
||||
}
|
||||
if (response is Iterable<String>) {
|
||||
return Stream<String>.fromIterable(response);
|
||||
}
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
|
||||
Future<Response<T>> _handleRequest<T>(
|
||||
String method,
|
||||
String path, {
|
||||
@@ -55,11 +143,17 @@ class MockApiClient implements IApiClient {
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
final key = '$path:$method';
|
||||
final handler = _handlers[key];
|
||||
final handler = _resolveHandler(path: path, method: method);
|
||||
|
||||
if (handler != null) {
|
||||
final response = handler(data);
|
||||
final response = handler(
|
||||
MockRequest(
|
||||
path: path,
|
||||
method: method,
|
||||
data: data,
|
||||
options: options,
|
||||
),
|
||||
);
|
||||
if (response is Response) {
|
||||
return response as Response<T>;
|
||||
}
|
||||
@@ -76,4 +170,21 @@ class MockApiClient implements IApiClient {
|
||||
requestOptions: RequestOptions(path: path),
|
||||
);
|
||||
}
|
||||
|
||||
MockHandler? _resolveHandler({required String path, required String method}) {
|
||||
final key = '$path:$method';
|
||||
final direct = _handlers[key];
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
for (final route in _patternHandlers) {
|
||||
if (route.method != method.toUpperCase()) {
|
||||
continue;
|
||||
}
|
||||
if (route.pattern.hasMatch(path)) {
|
||||
return route.handler;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user