feat(apps/chat): 新增 UI Schema 导航和路由导航工具
This commit is contained in:
@@ -177,7 +177,7 @@ class TextMessageEndEvent extends AgUiEvent {
|
|||||||
answer: _asString(json['answer']),
|
answer: _asString(json['answer']),
|
||||||
role: _asString(json['role'], fallback: 'assistant'),
|
role: _asString(json['role'], fallback: 'assistant'),
|
||||||
status: _asString(json['status'], fallback: 'success'),
|
status: _asString(json['status'], fallback: 'success'),
|
||||||
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
|
uiSchema: _asMap(json['ui_schema']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,17 +238,12 @@ class ToolCallResultEvent extends AgUiEvent {
|
|||||||
|
|
||||||
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
|
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
ToolCallResultEvent(
|
ToolCallResultEvent(
|
||||||
messageId: _asString(
|
messageId: _asString(json['messageId']),
|
||||||
json['messageId'],
|
toolCallId: _asString(json['tool_call_id']),
|
||||||
fallback: 'tool-${_asString(json['tool_call_id'])}',
|
toolName: _asString(json['tool_name']),
|
||||||
),
|
resultSummary: _asString(json['result']),
|
||||||
toolCallId: _asString(json['tool_call_id'] ?? json['toolCallId']),
|
|
||||||
toolName: _asString(json['tool_name'] ?? json['toolName']),
|
|
||||||
resultSummary: _asString(
|
|
||||||
json['result_summary'] ?? json['resultSummary'],
|
|
||||||
),
|
|
||||||
status: _asString(json['status'], fallback: 'success'),
|
status: _asString(json['status'], fallback: 'success'),
|
||||||
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
|
uiSchema: _asMap(json['ui_schema']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +323,7 @@ class HistoryMessage {
|
|||||||
timestamp:
|
timestamp:
|
||||||
DateTime.tryParse(_asString(json['timestamp'])) ?? DateTime.now(),
|
DateTime.tryParse(_asString(json['timestamp'])) ?? DateTime.now(),
|
||||||
attachments: _parseHistoryAttachments(json['attachments']),
|
attachments: _parseHistoryAttachments(json['attachments']),
|
||||||
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
|
uiSchema: _asMap(json['ui_schema']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
|
import '../../../../core/router/app_routes.dart';
|
||||||
|
|
||||||
typedef RouteNavigator = void Function(String target, {bool replace});
|
typedef RouteNavigator = void Function(String target, {bool replace});
|
||||||
|
|
||||||
const Set<String> _allowedRoutes = {
|
const Set<String> _allowedRoutes = {
|
||||||
'/settings',
|
AppRoutes.settingsMain,
|
||||||
'/todo',
|
AppRoutes.todoList,
|
||||||
'/calendar/dayweek',
|
AppRoutes.todoCreate,
|
||||||
'/messages/invites',
|
AppRoutes.calendarDayWeek,
|
||||||
|
AppRoutes.calendarMonth,
|
||||||
|
AppRoutes.calendarEventCreate,
|
||||||
|
AppRoutes.messageInviteList,
|
||||||
|
AppRoutes.contactsList,
|
||||||
|
AppRoutes.contactsAdd,
|
||||||
};
|
};
|
||||||
|
|
||||||
const List<String> _allowedRoutePrefixes = [
|
const List<String> _allowedRoutePrefixes = [
|
||||||
'/calendar/events/',
|
'/calendar/events/',
|
||||||
|
'/todo/',
|
||||||
|
'/messages/invites/',
|
||||||
];
|
];
|
||||||
|
|
||||||
class RouteNavigationTool {
|
class RouteNavigationTool {
|
||||||
@@ -29,17 +38,10 @@ class RouteNavigationTool {
|
|||||||
Map<String, dynamic> execute(Map<String, dynamic> args) {
|
Map<String, dynamic> execute(Map<String, dynamic> args) {
|
||||||
final target = args['target'];
|
final target = args['target'];
|
||||||
if (target is! String || target.isEmpty) {
|
if (target is! String || target.isEmpty) {
|
||||||
return {
|
return {'ok': false, 'error': 'target is required'};
|
||||||
'ok': false,
|
|
||||||
'error': 'target is required',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (!_isAllowedTarget(target)) {
|
if (!_isAllowedTarget(target)) {
|
||||||
return {
|
return {'ok': false, 'target': target, 'error': 'target is not allowed'};
|
||||||
'ok': false,
|
|
||||||
'target': target,
|
|
||||||
'error': 'target is not allowed',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
final replace = args['replace'] == true;
|
final replace = args['replace'] == true;
|
||||||
final navigator = _navigator;
|
final navigator = _navigator;
|
||||||
@@ -52,12 +54,7 @@ class RouteNavigationTool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
navigator(target, replace: replace);
|
navigator(target, replace: replace);
|
||||||
return {
|
return {'ok': true, 'target': target, 'replace': replace, 'applied': true};
|
||||||
'ok': true,
|
|
||||||
'target': target,
|
|
||||||
'replace': replace,
|
|
||||||
'applied': true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isAllowedTarget(String target) {
|
bool _isAllowedTarget(String target) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
enum AgentStage { intent, execution }
|
enum AgentStage { execution, memory }
|
||||||
|
|
||||||
AgentStage? stageFromStepName(String value) {
|
AgentStage? stageFromStepName(String value) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'router':
|
|
||||||
return AgentStage.intent;
|
|
||||||
case 'worker':
|
case 'worker':
|
||||||
return AgentStage.execution;
|
return AgentStage.execution;
|
||||||
|
case 'memory':
|
||||||
|
return AgentStage.memory;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,8 @@ AgentStage? stageFromStepName(String value) {
|
|||||||
|
|
||||||
String stageLabel(AgentStage? stage) {
|
String stageLabel(AgentStage? stage) {
|
||||||
return switch (stage) {
|
return switch (stage) {
|
||||||
AgentStage.intent => '意图识别中',
|
|
||||||
AgentStage.execution => '任务执行中',
|
AgentStage.execution => '任务执行中',
|
||||||
|
AgentStage.memory => '记忆提取中',
|
||||||
null => '任务处理中',
|
null => '任务处理中',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
bool isValidInternalNavigationPath(String path) {
|
||||||
|
if (path.isEmpty || !path.startsWith('/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !path.startsWith('//') &&
|
||||||
|
!path.contains('://') &&
|
||||||
|
!path.contains('?') &&
|
||||||
|
!path.contains('#') &&
|
||||||
|
!path.contains(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildUiSchemaNavigationTarget({
|
||||||
|
required String path,
|
||||||
|
Map<String, dynamic>? params,
|
||||||
|
}) {
|
||||||
|
final baseUri = Uri.parse(path);
|
||||||
|
final queryParams = <String, String>{};
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
for (final entry in params.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
queryParams[entry.key] = value;
|
||||||
|
} else if (value is num || value is bool) {
|
||||||
|
queryParams[entry.key] = value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mergedQueryParams = {...baseUri.queryParameters, ...queryParams};
|
||||||
|
final targetUri = baseUri.replace(
|
||||||
|
queryParameters: mergedQueryParams.isEmpty ? null : mergedQueryParams,
|
||||||
|
);
|
||||||
|
return targetUri.toString();
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:social_app/core/theme/design_tokens.dart';
|
import 'package:social_app/core/theme/design_tokens.dart';
|
||||||
import 'package:social_app/shared/widgets/toast/toast.dart';
|
import 'package:social_app/shared/widgets/toast/toast.dart';
|
||||||
import 'package:social_app/shared/widgets/toast/toast_type.dart';
|
import 'package:social_app/shared/widgets/toast/toast_type.dart';
|
||||||
|
import '../navigation/ui_schema_navigation.dart';
|
||||||
|
|
||||||
class UiSchemaRenderer {
|
class UiSchemaRenderer {
|
||||||
static Widget renderSchema(Map<String, dynamic>? schema) {
|
static Widget renderSchema(Map<String, dynamic>? schema) {
|
||||||
@@ -226,22 +227,16 @@ class UiSchemaRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final path = _asString(action['path']).trim();
|
final path = _asString(action['path']).trim();
|
||||||
if (!_isValidInternalPath(path)) {
|
if (!isValidInternalNavigationPath(path)) {
|
||||||
Toast.show(context, '导航路径无效', type: ToastType.warning);
|
Toast.show(context, '导航路径无效', type: ToastType.warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final params = _asMap(action['params']);
|
final params = _asMap(action['params']);
|
||||||
final replace = action['replace'] == true;
|
final shouldReplace = action['replace'] == true;
|
||||||
final queryParams = _extractNavigationQueryParams(params);
|
|
||||||
try {
|
try {
|
||||||
final baseUri = Uri.parse(path);
|
final target = buildUiSchemaNavigationTarget(path: path, params: params);
|
||||||
final mergedQueryParams = {...baseUri.queryParameters, ...queryParams};
|
if (shouldReplace) {
|
||||||
final targetUri = baseUri.replace(
|
|
||||||
queryParameters: mergedQueryParams.isEmpty ? null : mergedQueryParams,
|
|
||||||
);
|
|
||||||
final target = targetUri.toString();
|
|
||||||
if (replace) {
|
|
||||||
context.replace(target);
|
context.replace(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -251,38 +246,6 @@ class UiSchemaRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isValidInternalPath(String path) {
|
|
||||||
if (path.isEmpty || !path.startsWith('/')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (path.startsWith('//') || path.contains('://')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (path.contains('?') || path.contains('#') || path.contains(':')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, String> _extractNavigationQueryParams(
|
|
||||||
Map<String, dynamic>? params,
|
|
||||||
) {
|
|
||||||
if (params == null || params.isEmpty) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
final query = <String, String>{};
|
|
||||||
params.forEach((key, value) {
|
|
||||||
if (value is String && value.isNotEmpty) {
|
|
||||||
query[key] = value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value is num || value is bool) {
|
|
||||||
query[key] = value.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Widget _renderKv(Map<String, dynamic> node) {
|
static Widget _renderKv(Map<String, dynamic> node) {
|
||||||
final items = _asList(
|
final items = _asList(
|
||||||
node['items'],
|
node['items'],
|
||||||
@@ -406,17 +369,14 @@ class UiSchemaRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static List<Widget> _withGap(List<Widget> widgets, double gap) {
|
static List<Widget> _withGap(List<Widget> widgets, double gap) {
|
||||||
if (widgets.isEmpty) {
|
if (widgets.isEmpty) return const [];
|
||||||
return const [];
|
return [
|
||||||
}
|
widgets.first,
|
||||||
final result = <Widget>[];
|
for (int i = 1; i < widgets.length; i++) ...[
|
||||||
for (var i = 0; i < widgets.length; i++) {
|
SizedBox(height: gap),
|
||||||
if (i > 0) {
|
widgets[i],
|
||||||
result.add(SizedBox(height: gap));
|
],
|
||||||
}
|
];
|
||||||
result.add(widgets[i]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Color _statusBackground(String status) {
|
static Color _statusBackground(String status) {
|
||||||
@@ -454,13 +414,7 @@ class UiSchemaRenderer {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (value is Map) {
|
if (value is Map) {
|
||||||
final result = <String, dynamic>{};
|
return Map<String, dynamic>.from(value);
|
||||||
for (final entry in value.entries) {
|
|
||||||
if (entry.key is String) {
|
|
||||||
result[entry.key as String] = entry.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ void main() {
|
|||||||
'tool_call_id': 'call_1',
|
'tool_call_id': 'call_1',
|
||||||
'tool_name': 'calendar_read',
|
'tool_name': 'calendar_read',
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'result_summary': '找到 2 条结果',
|
'result': '找到 2 条结果',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(event, isA<ToolCallResultEvent>());
|
expect(event, isA<ToolCallResultEvent>());
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ import 'package:social_app/features/chat/presentation/bloc/agent_stage.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('agent stage mapping', () {
|
group('agent stage mapping', () {
|
||||||
test('maps protocol step router to intent stage label', () {
|
|
||||||
final stage = stageFromStepName('router');
|
|
||||||
|
|
||||||
expect(stage, AgentStage.intent);
|
|
||||||
expect(stageLabel(stage), '意图识别中');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('maps protocol step worker to execution stage label', () {
|
test('maps protocol step worker to execution stage label', () {
|
||||||
final stage = stageFromStepName('worker');
|
final stage = stageFromStepName('worker');
|
||||||
|
|
||||||
@@ -17,6 +10,13 @@ void main() {
|
|||||||
expect(stageLabel(stage), '任务执行中');
|
expect(stageLabel(stage), '任务执行中');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('maps protocol step memory to memory stage label', () {
|
||||||
|
final stage = stageFromStepName('memory');
|
||||||
|
|
||||||
|
expect(stage, AgentStage.memory);
|
||||||
|
expect(stageLabel(stage), '记忆提取中');
|
||||||
|
});
|
||||||
|
|
||||||
test('uses processing label when step is unknown', () {
|
test('uses processing label when step is unknown', () {
|
||||||
final stage = stageFromStepName('unexpected');
|
final stage = stageFromStepName('unexpected');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:social_app/features/chat/ui/navigation/ui_schema_navigation.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('buildUiSchemaNavigationTarget merges scalar params only', () {
|
||||||
|
final target = buildUiSchemaNavigationTarget(
|
||||||
|
path: '/calendar/dayweek',
|
||||||
|
params: {
|
||||||
|
'date': '2026-03-18',
|
||||||
|
'from': 'home',
|
||||||
|
'count': 2,
|
||||||
|
'enabled': true,
|
||||||
|
'ignored': {'nested': true},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
target,
|
||||||
|
'/calendar/dayweek?date=2026-03-18&from=home&count=2&enabled=true',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isValidInternalNavigationPath follows protocol constraints', () {
|
||||||
|
expect(isValidInternalNavigationPath('/todo/123/edit'), true);
|
||||||
|
expect(isValidInternalNavigationPath('https://evil.com'), false);
|
||||||
|
expect(isValidInternalNavigationPath('/todo/123?x=1'), false);
|
||||||
|
expect(isValidInternalNavigationPath('/todo/:id'), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user