chore: 更新国际化翻译及 UI 组件优化

This commit is contained in:
zl-q
2026-03-30 09:07:30 +08:00
parent 0f3175e303
commit 60318b7aaa
28 changed files with 1360 additions and 66 deletions
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/chat/ag_ui_event.dart';
void main() {
test('history message timestamp is normalized to local time', () {
final raw = <String, dynamic>{
'id': 'm1',
'seq': 1,
'role': 'assistant',
'content': 'hello',
'timestamp': '2026-03-29T16:06:27.870001+00:00',
'attachments': const [],
};
final message = HistoryMessage.fromJson(raw);
final expected = DateTime.parse(
'2026-03-29T16:06:27.870001+00:00',
).toLocal();
expect(message.timestamp.isUtc, isFalse);
expect(message.timestamp, expected);
});
}
+126
View File
@@ -0,0 +1,126 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/chat/ag_ui_event.dart';
import 'package:social_app/core/chat/ag_ui_service.dart';
import 'package:social_app/core/chat/chat_api.dart';
class _RetryableSseChatApi implements ChatApi {
int streamCalls = 0;
@override
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) async {
return <String, dynamic>{'threadId': 'thread-1', 'runId': 'run-1'};
}
@override
Future<Stream<String>> streamRunEvents(
String threadId, {
required String runId,
String? lastEventId,
}) async {
streamCalls += 1;
if (streamCalls == 1) {
return Stream<String>.fromIterable(<String>[
'id: e-1',
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
]);
}
return Stream<String>.fromIterable(<String>[
'id: e-2',
'event: TEXT_MESSAGE_END',
'data: {"type":"TEXT_MESSAGE_END","threadId":"thread-1","runId":"run-1","messageId":"m-assistant-1","answer":"ok","role":"assistant","status":"success"}',
'',
'id: e-3',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
]);
}
@override
Future<void> cancelRun({required String threadId, required String runId}) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> fetchHistory({
String? threadId,
DateTime? beforeDate,
}) {
throw UnimplementedError();
}
@override
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
throw UnimplementedError();
}
@override
Future<String> transcribeAudio(String filePath) {
throw UnimplementedError();
}
@override
Future<Map<String, dynamic>> uploadAttachment({
required String threadId,
required String filename,
required String mimeType,
required Uint8List bytes,
}) {
throw UnimplementedError();
}
}
class _AlwaysPrematureCloseChatApi extends _RetryableSseChatApi {
@override
Future<Stream<String>> streamRunEvents(
String threadId, {
required String runId,
String? lastEventId,
}) async {
streamCalls += 1;
return Stream<String>.fromIterable(<String>[
'id: e-1',
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
]);
}
}
void main() {
test(
'reconnects SSE stream when first attempt closes before terminal',
() async {
final chatApi = _RetryableSseChatApi();
final events = <AgUiEventType>[];
final service = AgUiService(
chatApi: chatApi,
onEvent: (event) {
events.add(event.type);
},
);
await service.sendMessage('hello');
expect(chatApi.streamCalls, 2);
expect(events.contains(AgUiEventType.runStarted), isTrue);
expect(events.contains(AgUiEventType.textMessageEnd), isTrue);
expect(events.contains(AgUiEventType.runFinished), isTrue);
},
);
test('throws after SSE resume attempts are exhausted', () async {
final chatApi = _AlwaysPrematureCloseChatApi();
final service = AgUiService(chatApi: chatApi);
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
expect(chatApi.streamCalls, 3);
});
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/chat/agent_stage.dart';
void main() {
test('maps router/worker/memory step names exactly', () {
expect(stageFromStepName('router'), AgentStage.routing);
expect(stageFromStepName('worker'), AgentStage.execution);
expect(stageFromStepName('memory'), AgentStage.memory);
});
test('normalizes step name with trim and case', () {
expect(stageFromStepName(' ROUTER '), AgentStage.routing);
expect(stageFromStepName('Worker'), AgentStage.execution);
expect(stageFromStepName(' MEMORY'), AgentStage.memory);
});
test('returns null for unknown step name', () {
expect(stageFromStepName('tool'), isNull);
expect(stageFromStepName(''), isNull);
expect(stageFromStepName('unknown'), isNull);
});
}
@@ -0,0 +1,190 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/chat/chat_list_item.dart';
import 'package:social_app/core/chat/chat_timeline_reconciler.dart';
void main() {
test('replaces optimistic user echo with remote persisted user message', () {
final now = DateTime(2026, 3, 30, 0, 6, 0);
final local = <ChatListItem>[
TextMessageItem(
id: 'user-local-1',
content: '随便推荐,比如说给我推荐B站的主页。',
timestamp: now,
sender: MessageSender.user,
isLocalEcho: true,
),
];
final remote = <ChatListItem>[
TextMessageItem(
id: 'db-user-27',
content: '随便推荐,比如说给我推荐B站的主页。',
timestamp: now.add(const Duration(seconds: 2)),
sender: MessageSender.user,
),
TextMessageItem(
id: 'db-assistant-30',
content: 'B站(哔哩哔哩)的主页链接是:https://www.bilibili.com/',
timestamp: now.add(const Duration(seconds: 6)),
sender: MessageSender.ai,
),
];
final merged = ChatTimelineReconciler.merge(
localItems: local,
remoteItems: remote,
);
expect(
merged
.whereType<TextMessageItem>()
.where((m) => m.sender == MessageSender.user)
.length,
1,
);
expect(merged.any((item) => item.id == 'user-local-1'), isFalse);
expect(merged.any((item) => item.id == 'db-user-27'), isTrue);
expect(merged.any((item) => item.id == 'db-assistant-30'), isTrue);
});
test('keeps optimistic user echo when remote does not match content', () {
final now = DateTime(2026, 3, 30, 0, 6, 0);
final local = <ChatListItem>[
TextMessageItem(
id: 'user-local-1',
content: 'A',
timestamp: now,
sender: MessageSender.user,
isLocalEcho: true,
),
];
final remote = <ChatListItem>[
TextMessageItem(
id: 'db-user-2',
content: 'B',
timestamp: now.add(const Duration(seconds: 1)),
sender: MessageSender.user,
),
];
final merged = ChatTimelineReconciler.merge(
localItems: local,
remoteItems: remote,
);
expect(merged.any((item) => item.id == 'user-local-1'), isTrue);
expect(merged.any((item) => item.id == 'db-user-2'), isTrue);
});
test(
'dedupes attachment message even when local uses path and remote uses url',
() {
final now = DateTime(2026, 3, 30, 0, 6, 0);
final local = <ChatListItem>[
TextMessageItem(
id: 'user-local-attachment',
content: '看这张图',
timestamp: now,
sender: MessageSender.user,
isLocalEcho: true,
attachments: const [
{'path': '/tmp/a.jpg', 'mimeType': 'image/jpeg'},
],
),
];
final remote = <ChatListItem>[
TextMessageItem(
id: 'db-user-attachment',
content: '看这张图',
timestamp: now.add(const Duration(seconds: 3)),
sender: MessageSender.user,
attachments: const [
{'url': 'https://cdn.example.com/a.jpg', 'mimeType': 'image/jpeg'},
],
),
];
final merged = ChatTimelineReconciler.merge(
localItems: local,
remoteItems: remote,
);
expect(merged.any((item) => item.id == 'user-local-attachment'), isFalse);
expect(merged.any((item) => item.id == 'db-user-attachment'), isTrue);
},
);
test(
'matches nearest optimistic echo when same text sent multiple times',
() {
final base = DateTime(2026, 3, 30, 0, 6, 0);
final local = <ChatListItem>[
TextMessageItem(
id: 'echo-older',
content: '你好',
timestamp: base,
sender: MessageSender.user,
isLocalEcho: true,
),
TextMessageItem(
id: 'echo-newer',
content: '你好',
timestamp: base.add(const Duration(seconds: 45)),
sender: MessageSender.user,
isLocalEcho: true,
),
];
final remote = <ChatListItem>[
TextMessageItem(
id: 'db-user-newer',
content: '你好',
timestamp: base.add(const Duration(seconds: 47)),
sender: MessageSender.user,
),
];
final merged = ChatTimelineReconciler.merge(
localItems: local,
remoteItems: remote,
);
expect(merged.any((item) => item.id == 'echo-older'), isTrue);
expect(merged.any((item) => item.id == 'echo-newer'), isFalse);
expect(merged.any((item) => item.id == 'db-user-newer'), isTrue);
},
);
test('does not dedupe when attachment identity differs', () {
final now = DateTime(2026, 3, 30, 0, 6, 0);
final local = <ChatListItem>[
TextMessageItem(
id: 'echo-attachment-1',
content: '看这张图',
timestamp: now,
sender: MessageSender.user,
isLocalEcho: true,
attachments: const [
{'path': '/tmp/a.jpg', 'mimeType': 'image/jpeg'},
],
),
];
final remote = <ChatListItem>[
TextMessageItem(
id: 'db-attachment-2',
content: '看这张图',
timestamp: now.add(const Duration(seconds: 2)),
sender: MessageSender.user,
attachments: const [
{'url': 'https://cdn.example.com/b.jpg', 'mimeType': 'image/jpeg'},
],
),
];
final merged = ChatTimelineReconciler.merge(
localItems: local,
remoteItems: remote,
);
expect(merged.any((item) => item.id == 'echo-attachment-1'), isTrue);
expect(merged.any((item) => item.id == 'db-attachment-2'), isTrue);
});
}