refactor: 移除前端 Mock API,新增共享组件,优化认证流程
- 删除 mock_api_client、mock_calendar_service、mock_history_service - 新增 fixed_length_code_input、link_button、message_composer 共享组件 - 优化登录/注册/密码重置页面使用新组件 - 简化 injection.dart 移除 mock 分支 - 更新 env.dart 配置(BACKEND_URL 替换 API_URL) - 后端 agentscope 工具和测试更新 - 重构 AGENTS.md 文档结构 - 新增 deploy/ 目录和 protocol 文档
This commit is contained in:
@@ -6,16 +6,13 @@ import 'dart:typed_data';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/api/mock_api_client.dart';
|
||||
|
||||
import '../ai/ai_decision_engine.dart';
|
||||
import '../models/ag_ui_event.dart';
|
||||
import '../tools/tool_registry.dart';
|
||||
import 'mock_history_service.dart';
|
||||
|
||||
typedef EventCallback = void Function(AgUiEvent event);
|
||||
|
||||
/// ID 前缀常量
|
||||
const _runIdPrefix = 'run_';
|
||||
const _messageIdPrefix = 'msg_';
|
||||
const _toolCallIdPrefix = 'tc_';
|
||||
@@ -24,24 +21,16 @@ class AgUiService {
|
||||
final IApiClient _apiClient;
|
||||
EventCallback onEvent;
|
||||
final AiDecisionEngine _decisionEngine;
|
||||
final MockHistoryService _historyService;
|
||||
final Map<String, List<String>> _mockSseLinesByThread = {};
|
||||
final Map<String, String> _lastEventIdByThread = {};
|
||||
int _activeStreamToken = 0;
|
||||
|
||||
String? _threadId;
|
||||
bool _hasMoreHistory = false;
|
||||
bool _mockApiConfigured = false;
|
||||
|
||||
AgUiService({EventCallback? onEvent, IApiClient? apiClient})
|
||||
AgUiService({EventCallback? onEvent, required IApiClient apiClient})
|
||||
: onEvent = onEvent ?? ((_) {}),
|
||||
_apiClient = apiClient ?? MockApiClient(),
|
||||
_decisionEngine = AiDecisionEngine(),
|
||||
_historyService = MockHistoryService() {
|
||||
if (_apiClient is MockApiClient) {
|
||||
_configureMockAgentApi(_apiClient);
|
||||
}
|
||||
}
|
||||
_apiClient = apiClient,
|
||||
_decisionEngine = AiDecisionEngine();
|
||||
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
final streamToken = ++_activeStreamToken;
|
||||
@@ -409,368 +398,4 @@ class AgUiService {
|
||||
const variant = ['8', '9', 'a', 'b'];
|
||||
return '${hex(8)}-${hex(4)}-4${hex(3)}-${variant[random.nextInt(4)]}${hex(3)}-${hex(12)}';
|
||||
}
|
||||
|
||||
void _configureMockAgentApi(MockApiClient client) {
|
||||
if (_mockApiConfigured) {
|
||||
return;
|
||||
}
|
||||
_mockApiConfigured = true;
|
||||
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', _handleMockRun);
|
||||
client.registerPatternHandler(
|
||||
RegExp(r'^/api/v1/agent/runs/[^/]+/resume$'),
|
||||
'POST',
|
||||
_handleMockResume,
|
||||
);
|
||||
client.registerPatternHandler(
|
||||
RegExp(r'^/api/v1/agent/history(?:\?.*)?$'),
|
||||
'GET',
|
||||
_handleMockHistory,
|
||||
);
|
||||
client.registerPatternHandler(
|
||||
RegExp(r'^/api/v1/agent/runs/[^/]+/events$'),
|
||||
'SSE',
|
||||
_handleMockSse,
|
||||
);
|
||||
client.registerHandler(
|
||||
'/api/v1/agent/attachments',
|
||||
'POST',
|
||||
_handleMockUploadAttachment,
|
||||
);
|
||||
client.registerHandler(
|
||||
'/api/v1/agent/transcribe',
|
||||
'POST',
|
||||
_handleMockTranscribe,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockTranscribe(MockRequest request) {
|
||||
return {'transcript': '这是模拟语音转写'};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockUploadAttachment(MockRequest request) {
|
||||
final payload = request.data;
|
||||
final threadId = payload is Map<String, dynamic>
|
||||
? (payload['threadId'] as String?)
|
||||
: null;
|
||||
final resolvedThreadId = (threadId != null && threadId.isNotEmpty)
|
||||
? threadId
|
||||
: (_threadId ?? _newUuid());
|
||||
final path =
|
||||
'agent-inputs/mock/$resolvedThreadId/${_nextId('upload_')}.png';
|
||||
return {
|
||||
'attachment': {
|
||||
'bucket': 'mock-bucket',
|
||||
'path': path,
|
||||
'mimeType': 'image/png',
|
||||
'url': 'https://mock.local/$path',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockRun(MockRequest request) {
|
||||
final payload = request.data;
|
||||
final runInput = payload is Map<String, dynamic>
|
||||
? payload
|
||||
: <String, dynamic>{};
|
||||
final threadId = (runInput['threadId'] as String?) ?? _newUuid();
|
||||
final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix);
|
||||
_threadId = threadId;
|
||||
|
||||
final content = _extractLatestUserContent(runInput);
|
||||
final events = _buildMockRunEvents(
|
||||
threadId: threadId,
|
||||
runId: runId,
|
||||
userInput: content,
|
||||
);
|
||||
_mockSseLinesByThread[threadId] = _toSseLines(events);
|
||||
return {
|
||||
'taskId': _nextId('task_'),
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
'created': false,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockResume(MockRequest request) {
|
||||
final match = RegExp(
|
||||
r'^/api/v1/agent/runs/([^/]+)/resume$',
|
||||
).firstMatch(request.path);
|
||||
final threadId = match?.group(1) ?? (_threadId ?? _newUuid());
|
||||
final payload = request.data;
|
||||
final runInput = payload is Map<String, dynamic>
|
||||
? payload
|
||||
: <String, dynamic>{};
|
||||
final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix);
|
||||
_threadId = threadId;
|
||||
|
||||
final toolMessage = _extractLatestToolMessage(runInput);
|
||||
final events = <Map<String, dynamic>>[
|
||||
{
|
||||
'type': AgUiEventTypeWire.runStarted,
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
},
|
||||
{
|
||||
'type': AgUiEventTypeWire.toolCallResult,
|
||||
'messageId': _nextId(_messageIdPrefix),
|
||||
'toolCallId': toolMessage.$1,
|
||||
'content': toolMessage.$2,
|
||||
},
|
||||
{
|
||||
'type': AgUiEventTypeWire.textMessageStart,
|
||||
'messageId': _nextId(_messageIdPrefix),
|
||||
'role': 'assistant',
|
||||
},
|
||||
{
|
||||
'type': AgUiEventTypeWire.textMessageContent,
|
||||
'messageId': _nextId(_messageIdPrefix),
|
||||
'delta': '已收到你的审批,继续执行完成。',
|
||||
},
|
||||
{
|
||||
'type': AgUiEventTypeWire.textMessageEnd,
|
||||
'messageId': _nextId(_messageIdPrefix),
|
||||
},
|
||||
{
|
||||
'type': AgUiEventTypeWire.runFinished,
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
},
|
||||
];
|
||||
_mockSseLinesByThread[threadId] = _toSseLines(events);
|
||||
return {
|
||||
'taskId': _nextId('task_'),
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
'created': false,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockHistory(MockRequest request) {
|
||||
final uri = Uri.parse(request.path);
|
||||
final query = uri.queryParameters;
|
||||
final providedThreadId = query['threadId'];
|
||||
final threadId = providedThreadId ?? _threadId ?? _newUuid();
|
||||
_threadId = threadId;
|
||||
|
||||
final beforeRaw = query['before'];
|
||||
DateTime? beforeDate;
|
||||
if (beforeRaw != null && beforeRaw.isNotEmpty) {
|
||||
beforeDate = DateTime.tryParse(beforeRaw);
|
||||
}
|
||||
|
||||
DateTime? targetDate;
|
||||
if (beforeDate == null) {
|
||||
targetDate = _historyService.getLatestHistoryDate();
|
||||
} else {
|
||||
targetDate = _historyService.getPreviousDay(beforeDate);
|
||||
}
|
||||
final messages = targetDate == null
|
||||
? <SnapshotMessage>[]
|
||||
: _historyService.getHistoryForDay(targetDate);
|
||||
final hasMore =
|
||||
targetDate != null && _historyService.hasEarlierHistory(targetDate);
|
||||
_hasMoreHistory = hasMore;
|
||||
|
||||
return {
|
||||
'type': AgUiEventTypeWire.stateSnapshot,
|
||||
'threadId': threadId,
|
||||
'snapshot': {
|
||||
'scope': 'history_day',
|
||||
'threadId': threadId,
|
||||
'day': targetDate == null
|
||||
? null
|
||||
: DateTime(
|
||||
targetDate.year,
|
||||
targetDate.month,
|
||||
targetDate.day,
|
||||
).toIso8601String().substring(0, 10),
|
||||
'hasMore': hasMore,
|
||||
'messages': messages.map((item) => item.toJson()).toList(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Stream<String> _handleMockSse(MockRequest request) {
|
||||
final match = RegExp(
|
||||
r'^/api/v1/agent/runs/([^/]+)/events$',
|
||||
).firstMatch(request.path);
|
||||
final threadId = match?.group(1);
|
||||
if (threadId == null) {
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
final lines = _mockSseLinesByThread[threadId];
|
||||
if (lines == null) {
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
return Stream<String>.fromIterable(lines);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildMockRunEvents({
|
||||
required String threadId,
|
||||
required String runId,
|
||||
required String userInput,
|
||||
}) {
|
||||
final events = <Map<String, dynamic>>[
|
||||
{
|
||||
'type': AgUiEventTypeWire.runStarted,
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
},
|
||||
];
|
||||
|
||||
final forceTrigger = _decisionEngine.tryForceTrigger(userInput);
|
||||
Map<String, dynamic>? args;
|
||||
String? toolName;
|
||||
if (forceTrigger != null) {
|
||||
toolName = forceTrigger.toolName;
|
||||
args = forceTrigger.args;
|
||||
} else if (_looksLikeNavigationIntent(userInput)) {
|
||||
toolName = 'front.navigate_to_route';
|
||||
args = {'target': _inferNavigationRoute(userInput), 'replace': false};
|
||||
}
|
||||
|
||||
if (toolName != null && args != null) {
|
||||
if (toolName == 'front.navigate_to_route') {
|
||||
args = {...args, '__nonce': _nextId('nonce_')};
|
||||
}
|
||||
final toolCallId = _nextId(_toolCallIdPrefix);
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallStart,
|
||||
'toolCallId': toolCallId,
|
||||
'toolCallName': toolName,
|
||||
});
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallArgs,
|
||||
'toolCallId': toolCallId,
|
||||
'delta': jsonEncode(args),
|
||||
});
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallEnd,
|
||||
'toolCallId': toolCallId,
|
||||
});
|
||||
|
||||
if (toolName == 'front.navigate_to_route') {
|
||||
// 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。
|
||||
} else {
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallError,
|
||||
'toolCallId': toolCallId,
|
||||
'error': 'Unsupported frontend tool in mock mode',
|
||||
'code': 'UNSUPPORTED_TOOL',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final replies = _generateReplies(userInput);
|
||||
for (final reply in replies) {
|
||||
final messageId = _nextId(_messageIdPrefix);
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.textMessageStart,
|
||||
'messageId': messageId,
|
||||
'role': 'assistant',
|
||||
});
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.textMessageContent,
|
||||
'messageId': messageId,
|
||||
'delta': reply,
|
||||
});
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.textMessageEnd,
|
||||
'messageId': messageId,
|
||||
});
|
||||
}
|
||||
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.runFinished,
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
List<String> _toSseLines(List<Map<String, dynamic>> events) {
|
||||
final lines = <String>[];
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
final event = events[i];
|
||||
final eventType = event['type'] as String? ?? 'MESSAGE';
|
||||
final eventId = '${i + 1}-0';
|
||||
lines.add('id: $eventId');
|
||||
lines.add('event: $eventType');
|
||||
lines.add('data: ${jsonEncode(event)}');
|
||||
lines.add('');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
String _extractLatestUserContent(Map<String, dynamic> runInput) {
|
||||
final messages = runInput['messages'];
|
||||
if (messages is! List<dynamic>) {
|
||||
return '';
|
||||
}
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
final raw = messages[i];
|
||||
if (raw is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
if (raw['role'] != 'user') {
|
||||
continue;
|
||||
}
|
||||
final content = raw['content'];
|
||||
if (content is String) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
(String, String) _extractLatestToolMessage(Map<String, dynamic> runInput) {
|
||||
final messages = runInput['messages'];
|
||||
if (messages is! List<dynamic>) {
|
||||
return (_nextId(_toolCallIdPrefix), '{}');
|
||||
}
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
final raw = messages[i];
|
||||
if (raw is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
if (raw['role'] != 'tool') {
|
||||
continue;
|
||||
}
|
||||
final toolCallId =
|
||||
raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
|
||||
final content = raw['content'] as String? ?? '{}';
|
||||
return (toolCallId, content);
|
||||
}
|
||||
return (_nextId(_toolCallIdPrefix), '{}');
|
||||
}
|
||||
|
||||
List<String> _generateReplies(String content) {
|
||||
final intent = _decisionEngine.matchIntent(content);
|
||||
switch (intent) {
|
||||
case Intent.createEvent:
|
||||
return ['好的,我已经为您创建了日程安排。'];
|
||||
case Intent.searchEvent:
|
||||
return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审'];
|
||||
case Intent.unknown:
|
||||
return ['我理解了您的问题,让我来帮您处理。'];
|
||||
}
|
||||
}
|
||||
|
||||
bool _looksLikeNavigationIntent(String input) {
|
||||
return input.contains('打开') ||
|
||||
input.contains('跳转') ||
|
||||
input.toLowerCase().contains('navigate') ||
|
||||
input.toLowerCase().contains('open');
|
||||
}
|
||||
|
||||
String _inferNavigationRoute(String input) {
|
||||
if (input.contains('设置')) {
|
||||
return '/settings';
|
||||
}
|
||||
if (input.contains('待办')) {
|
||||
return '/todo';
|
||||
}
|
||||
return '/calendar/dayweek';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import '../models/ag_ui_event.dart';
|
||||
import '../models/tool_result.dart';
|
||||
|
||||
class MockHistoryService {
|
||||
static final MockHistoryService _instance = MockHistoryService._internal();
|
||||
factory MockHistoryService() => _instance;
|
||||
MockHistoryService._internal();
|
||||
|
||||
/// Normalize DateTime to date-only (midnight)
|
||||
DateTime _toDateOnly(DateTime date) =>
|
||||
DateTime(date.year, date.month, date.day);
|
||||
|
||||
List<SnapshotMessage> getHistoryForDay(DateTime date) {
|
||||
final dayStart = _toDateOnly(date);
|
||||
final allHistory = _generateAllHistory();
|
||||
|
||||
return allHistory.where((msg) {
|
||||
if (msg.timestamp == null) return false;
|
||||
final msgDate = _toDateOnly(msg.timestamp!);
|
||||
return msgDate == dayStart;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
DateTime? getLatestHistoryDate() {
|
||||
final allHistory = _generateAllHistory();
|
||||
if (allHistory.isEmpty) return null;
|
||||
|
||||
return allHistory
|
||||
.where((msg) => msg.timestamp != null)
|
||||
.map((msg) => _toDateOnly(msg.timestamp!))
|
||||
.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
}
|
||||
|
||||
DateTime? getPreviousDay(DateTime currentDate) {
|
||||
final allDates = _getAllHistoryDates();
|
||||
final sortedDates = allDates.toList()..sort((a, b) => b.compareTo(a));
|
||||
final currentDateOnly = _toDateOnly(currentDate);
|
||||
|
||||
for (final date in sortedDates) {
|
||||
if (date.isBefore(currentDateOnly)) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool hasEarlierHistory(DateTime fromDate) {
|
||||
final allDates = _getAllHistoryDates();
|
||||
final fromDateOnly = _toDateOnly(fromDate);
|
||||
|
||||
return allDates.any((date) => date.isBefore(fromDateOnly));
|
||||
}
|
||||
|
||||
Set<DateTime> _getAllHistoryDates() {
|
||||
final now = DateTime.now();
|
||||
final today = _toDateOnly(now);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
return {today, yesterday};
|
||||
}
|
||||
|
||||
List<SnapshotMessage> _generateAllHistory() {
|
||||
final now = DateTime.now();
|
||||
final today = _toDateOnly(now);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
|
||||
return [
|
||||
SnapshotMessage(
|
||||
id: 'hist-m1',
|
||||
role: 'user',
|
||||
content: '明天提醒我开会',
|
||||
timestamp: today.add(const Duration(hours: 10)),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-t1',
|
||||
role: 'tool',
|
||||
toolCallId: 'hist-tc1',
|
||||
timestamp: today.add(const Duration(hours: 10)),
|
||||
ui: UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'hist-s1',
|
||||
title: '产品评审会议',
|
||||
description: '讨论Q2产品路线图',
|
||||
startAt: today
|
||||
.add(const Duration(days: 1, hours: 10))
|
||||
.toIso8601String(),
|
||||
endAt: today
|
||||
.add(const Duration(days: 1, hours: 11))
|
||||
.toIso8601String(),
|
||||
timezone: 'Asia/Shanghai',
|
||||
location: '会议室A / 在线',
|
||||
color: '#4F46E5',
|
||||
sourceType: 'ai_generated',
|
||||
).toJson(),
|
||||
actions: [
|
||||
CardAction(
|
||||
type: 'link',
|
||||
label: '查看详情',
|
||||
target: '/calendar/hist-s1',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-m2',
|
||||
role: 'assistant',
|
||||
content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。',
|
||||
timestamp: today.add(const Duration(hours: 10)),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-m3',
|
||||
role: 'user',
|
||||
content: '下周一之前提交项目报告',
|
||||
timestamp: yesterday.add(const Duration(hours: 14)),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-t2',
|
||||
role: 'tool',
|
||||
toolCallId: 'hist-tc2',
|
||||
timestamp: yesterday.add(const Duration(hours: 14)),
|
||||
ui: UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'hist-s2',
|
||||
title: '提交项目报告',
|
||||
description: '完成并提交Q2项目报告',
|
||||
startAt: yesterday.add(const Duration(days: 5)).toIso8601String(),
|
||||
endAt: null,
|
||||
timezone: 'Asia/Shanghai',
|
||||
location: null,
|
||||
color: '#F59E0B',
|
||||
sourceType: 'ai_generated',
|
||||
).toJson(),
|
||||
actions: [
|
||||
CardAction(
|
||||
type: 'link',
|
||||
label: '查看详情',
|
||||
target: '/calendar/hist-s2',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-m4',
|
||||
role: 'assistant',
|
||||
content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。',
|
||||
timestamp: yesterday.add(const Duration(hours: 14)),
|
||||
),
|
||||
SnapshotMessage(
|
||||
id: 'hist-m5',
|
||||
role: 'assistant',
|
||||
content: '你好,我有什么可以帮你的?',
|
||||
timestamp: yesterday.add(const Duration(hours: 9)),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user