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:
qzl
2026-03-12 16:41:45 +08:00
parent d7fbb74bf8
commit 01c36eb32e
70 changed files with 5138 additions and 5829 deletions
@@ -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)),
),
];
}
}
@@ -4,7 +4,6 @@ import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.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 'package:social_app/core/di/injection.dart';
import '../../data/models/ag_ui_event.dart';
@@ -91,16 +90,8 @@ class ChatBloc extends Cubit<ChatState> {
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
<String, Future<Uint8List?>>{};
ChatBloc({AgUiService? service, IApiClient? apiClient})
: _service =
service ??
AgUiService(
apiClient:
apiClient ??
(sl.isRegistered<IApiClient>()
? sl<IApiClient>()
: MockApiClient()),
),
ChatBloc({AgUiService? service, required IApiClient apiClient})
: _service = service ?? AgUiService(apiClient: apiClient),
super(const ChatState()) {
_service.onEvent = _handleEvent;
}