feat(apps/chat): 新增 UI Schema 导航和路由导航工具

This commit is contained in:
zl-q
2026-03-19 00:51:57 +08:00
parent bfc3096199
commit 81cbc14219
8 changed files with 113 additions and 103 deletions
@@ -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);
});
}