refactor: 重构聊天数据层至core并简化首页UI

This commit is contained in:
zl-q
2026-03-29 21:46:26 +08:00
parent 4db9a13bfe
commit f126d7a547
18 changed files with 568 additions and 328 deletions
@@ -0,0 +1,142 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:social_app/core/chat/chat_api.dart';
import 'package:social_app/data/network/i_api_client.dart';
class ChatApiImpl implements ChatApi {
ChatApiImpl(this._apiClient);
final IApiClient _apiClient;
@override
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) async {
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs',
data: runInput,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/runs response');
}
return payload;
}
@override
Future<Stream<String>> streamRunEvents(
String threadId, {
String? lastEventId,
}) {
final headers = <String, String>{'Accept': 'text/event-stream'};
if (lastEventId != null && lastEventId.isNotEmpty) {
headers['Last-Event-ID'] = lastEventId;
}
return _apiClient.getSseLines(
'/api/v1/agent/runs/$threadId/events',
headers: headers,
);
}
@override
Future<Map<String, dynamic>> fetchHistory({
String? threadId,
DateTime? beforeDate,
}) async {
final path = _buildHistoryPath(threadId: threadId, beforeDate: beforeDate);
final response = await _apiClient.get<Map<String, dynamic>>(path);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/history response');
}
return payload;
}
@override
Future<Map<String, dynamic>> uploadAttachment({
required String threadId,
required String filename,
required String mimeType,
required Uint8List bytes,
}) async {
final formData = FormData.fromMap({
'threadId': threadId,
'file': MultipartFile.fromBytes(
bytes,
filename: filename,
contentType: DioMediaType.parse(mimeType),
),
});
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/attachments',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/attachments response');
}
return payload;
}
@override
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
final response = await _apiClient.get<List<int>>(
previewPath,
options: Options(responseType: ResponseType.bytes),
);
final payload = response.data;
if (payload is! List<int>) {
throw StateError('Invalid attachment payload');
}
return Uint8List.fromList(payload);
}
@override
Future<String> transcribeAudio(String filePath) async {
final formData = FormData.fromMap({
'audio': await MultipartFile.fromFile(
filePath,
filename: 'recording.wav',
contentType: DioMediaType('audio', 'wav'),
),
});
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/transcribe',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/transcribe response');
}
final transcript = payload['transcript'];
if (transcript is! String) {
throw StateError('Missing transcript in /agent/transcribe response');
}
return transcript;
}
@override
Future<void> cancelRun({
required String threadId,
required String runId,
}) async {
final encodedRunId = Uri.encodeQueryComponent(runId);
await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs/$threadId/cancel?runId=$encodedRunId',
);
}
static String _buildHistoryPath({String? threadId, DateTime? beforeDate}) {
final query = <String>[];
if (threadId != null && threadId.isNotEmpty) {
query.add('threadId=${Uri.encodeQueryComponent(threadId)}');
}
if (beforeDate != null) {
final day = DateTime(beforeDate.year, beforeDate.month, beforeDate.day);
query.add('before=${day.toIso8601String().substring(0, 10)}');
}
if (query.isEmpty) {
return '/api/v1/agent/history';
}
return '/api/v1/agent/history?${query.join('&')}';
}
}
@@ -1,390 +0,0 @@
class AgUiEventTypeWire {
static const runStarted = 'RUN_STARTED';
static const runFinished = 'RUN_FINISHED';
static const runError = 'RUN_ERROR';
static const stepStarted = 'STEP_STARTED';
static const stepFinished = 'STEP_FINISHED';
static const textMessageEnd = 'TEXT_MESSAGE_END';
static const toolCallStart = 'TOOL_CALL_START';
static const toolCallArgs = 'TOOL_CALL_ARGS';
static const toolCallEnd = 'TOOL_CALL_END';
static const toolCallResult = 'TOOL_CALL_RESULT';
static const toolCallError = 'TOOL_CALL_ERROR';
}
enum AgUiEventType {
runStarted,
runFinished,
runError,
stepStarted,
stepFinished,
textMessageEnd,
toolCallStart,
toolCallArgs,
toolCallEnd,
toolCallResult,
toolCallError,
unknown,
}
const _wireToTypeMap = {
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
AgUiEventTypeWire.runError: AgUiEventType.runError,
AgUiEventTypeWire.stepStarted: AgUiEventType.stepStarted,
AgUiEventTypeWire.stepFinished: AgUiEventType.stepFinished,
AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd,
AgUiEventTypeWire.toolCallStart: AgUiEventType.toolCallStart,
AgUiEventTypeWire.toolCallArgs: AgUiEventType.toolCallArgs,
AgUiEventTypeWire.toolCallEnd: AgUiEventType.toolCallEnd,
AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult,
AgUiEventTypeWire.toolCallError: AgUiEventType.toolCallError,
};
const _typeToWireMap = {
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
AgUiEventType.runError: AgUiEventTypeWire.runError,
AgUiEventType.stepStarted: AgUiEventTypeWire.stepStarted,
AgUiEventType.stepFinished: AgUiEventTypeWire.stepFinished,
AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd,
AgUiEventType.toolCallStart: AgUiEventTypeWire.toolCallStart,
AgUiEventType.toolCallArgs: AgUiEventTypeWire.toolCallArgs,
AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd,
AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult,
AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError,
AgUiEventType.unknown: '',
};
AgUiEventType agUiEventTypeFromWire(String wire) =>
_wireToTypeMap[wire] ?? AgUiEventType.unknown;
String agUiEventTypeToWire(AgUiEventType type) => _typeToWireMap[type] ?? '';
abstract class AgUiEvent {
const AgUiEvent({required this.type});
final AgUiEventType type;
factory AgUiEvent.fromJson(Map<String, dynamic> json) {
final wireType = json['type'];
final type = wireType is String
? agUiEventTypeFromWire(wireType)
: AgUiEventType.unknown;
return switch (type) {
AgUiEventType.runStarted => RunStartedEvent.fromJson(json),
AgUiEventType.runFinished => RunFinishedEvent.fromJson(json),
AgUiEventType.runError => RunErrorEvent.fromJson(json),
AgUiEventType.stepStarted => StepStartedEvent.fromJson(json),
AgUiEventType.stepFinished => StepFinishedEvent.fromJson(json),
AgUiEventType.textMessageEnd => TextMessageEndEvent.fromJson(json),
AgUiEventType.toolCallStart => ToolCallStartEvent.fromJson(json),
AgUiEventType.toolCallArgs => ToolCallArgsEvent.fromJson(json),
AgUiEventType.toolCallEnd => ToolCallEndEvent.fromJson(json),
AgUiEventType.toolCallResult => ToolCallResultEvent.fromJson(json),
AgUiEventType.toolCallError => ToolCallErrorEvent.fromJson(json),
AgUiEventType.unknown => UnknownAgUiEvent(rawJson: json),
};
}
}
class UnknownAgUiEvent extends AgUiEvent {
const UnknownAgUiEvent({required this.rawJson})
: super(type: AgUiEventType.unknown);
final Map<String, dynamic> rawJson;
}
class RunStartedEvent extends AgUiEvent {
RunStartedEvent({required this.threadId, required this.runId})
: super(type: AgUiEventType.runStarted);
final String threadId;
final String runId;
factory RunStartedEvent.fromJson(Map<String, dynamic> json) =>
RunStartedEvent(
threadId: _asString(json['threadId']),
runId: _asString(json['runId']),
);
}
class RunFinishedEvent extends AgUiEvent {
RunFinishedEvent({required this.threadId, required this.runId})
: super(type: AgUiEventType.runFinished);
final String threadId;
final String runId;
factory RunFinishedEvent.fromJson(Map<String, dynamic> json) =>
RunFinishedEvent(
threadId: _asString(json['threadId']),
runId: _asString(json['runId']),
);
}
class RunErrorEvent extends AgUiEvent {
RunErrorEvent({required this.message, this.code})
: super(type: AgUiEventType.runError);
final String message;
final String? code;
factory RunErrorEvent.fromJson(Map<String, dynamic> json) => RunErrorEvent(
message: _asString(json['message'], fallback: 'Unknown error'),
code: json['code'] as String?,
);
}
class StepStartedEvent extends AgUiEvent {
StepStartedEvent({required this.stepName})
: super(type: AgUiEventType.stepStarted);
final String stepName;
factory StepStartedEvent.fromJson(Map<String, dynamic> json) =>
StepStartedEvent(stepName: _asString(json['stepName']));
}
class StepFinishedEvent extends AgUiEvent {
StepFinishedEvent({required this.stepName})
: super(type: AgUiEventType.stepFinished);
final String stepName;
factory StepFinishedEvent.fromJson(Map<String, dynamic> json) =>
StepFinishedEvent(stepName: _asString(json['stepName']));
}
class TextMessageEndEvent extends AgUiEvent {
TextMessageEndEvent({
required this.messageId,
required this.answer,
required this.role,
required this.status,
required this.uiSchema,
}) : super(type: AgUiEventType.textMessageEnd);
final String messageId;
final String answer;
final String role;
final String status;
final Map<String, dynamic>? uiSchema;
factory TextMessageEndEvent.fromJson(Map<String, dynamic> json) =>
TextMessageEndEvent(
messageId: _asString(json['messageId']),
answer: _asString(json['answer']),
role: _asString(json['role'], fallback: 'assistant'),
status: _asString(json['status'], fallback: 'success'),
uiSchema: _asMap(json['ui_schema']),
);
}
class ToolCallStartEvent extends AgUiEvent {
ToolCallStartEvent({required this.toolCallId, required this.toolCallName})
: super(type: AgUiEventType.toolCallStart);
final String toolCallId;
final String toolCallName;
factory ToolCallStartEvent.fromJson(Map<String, dynamic> json) =>
ToolCallStartEvent(
toolCallId: _asString(json['toolCallId']),
toolCallName: _asString(json['toolCallName']),
);
}
class ToolCallArgsEvent extends AgUiEvent {
ToolCallArgsEvent({required this.toolCallId, required this.args})
: super(type: AgUiEventType.toolCallArgs);
final String toolCallId;
final Map<String, dynamic> args;
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> json) =>
ToolCallArgsEvent(
toolCallId: _asString(json['toolCallId']),
args: _asMap(json['args']) ?? const {},
);
}
class ToolCallEndEvent extends AgUiEvent {
ToolCallEndEvent({required this.toolCallId})
: super(type: AgUiEventType.toolCallEnd);
final String toolCallId;
factory ToolCallEndEvent.fromJson(Map<String, dynamic> json) =>
ToolCallEndEvent(toolCallId: _asString(json['toolCallId']));
}
class ToolCallResultEvent extends AgUiEvent {
ToolCallResultEvent({
required this.messageId,
required this.toolCallId,
required this.toolName,
required this.resultSummary,
required this.status,
}) : super(type: AgUiEventType.toolCallResult);
final String messageId;
final String toolCallId;
final String toolName;
final String resultSummary;
final String status;
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
ToolCallResultEvent(
messageId: _asString(json['messageId']),
toolCallId: _asString(json['tool_call_id']),
toolName: _asString(json['tool_name']),
resultSummary: _asString(json['result']),
status: _asString(json['status'], fallback: 'success'),
);
}
class ToolCallErrorEvent extends AgUiEvent {
ToolCallErrorEvent({required this.toolCallId, required this.error, this.code})
: super(type: AgUiEventType.toolCallError);
final String toolCallId;
final String error;
final String? code;
factory ToolCallErrorEvent.fromJson(Map<String, dynamic> json) =>
ToolCallErrorEvent(
toolCallId: _asString(json['toolCallId']),
error: _asString(json['error'], fallback: 'Tool call failed'),
code: json['code'] as String?,
);
}
class HistorySnapshot {
const HistorySnapshot({
required this.scope,
required this.threadId,
required this.day,
required this.hasMore,
required this.messages,
});
final String scope;
final String? threadId;
final String? day;
final bool hasMore;
final List<HistoryMessage> messages;
factory HistorySnapshot.fromJson(Map<String, dynamic> json) {
final rawMessages = json['messages'];
final messages = rawMessages is List
? rawMessages
.whereType<Map<String, dynamic>>()
.map(HistoryMessage.fromJson)
.toList()
: const <HistoryMessage>[];
return HistorySnapshot(
scope: _asString(json['scope'], fallback: 'history_day'),
threadId: json['threadId'] as String?,
day: json['day'] as String?,
hasMore: json['hasMore'] == true,
messages: messages,
);
}
}
class HistoryMessage {
const HistoryMessage({
required this.id,
required this.seq,
required this.role,
required this.content,
required this.timestamp,
this.attachments = const <HistoryAttachment>[],
this.uiSchema,
});
final String id;
final int seq;
final String role;
final String content;
final DateTime timestamp;
final List<HistoryAttachment> attachments;
final Map<String, dynamic>? uiSchema;
factory HistoryMessage.fromJson(Map<String, dynamic> json) => HistoryMessage(
id: _asString(json['id']),
seq: _asInt(json['seq']),
role: _asString(json['role']),
content: _asString(json['content']),
timestamp:
DateTime.tryParse(_asString(json['timestamp'])) ?? DateTime.now(),
attachments: _parseHistoryAttachments(json['attachments']),
uiSchema: _asMap(json['ui_schema']),
);
}
class HistoryAttachment {
const HistoryAttachment({required this.url, required this.mimeType});
final String url;
final String mimeType;
factory HistoryAttachment.fromJson(Map<String, dynamic> json) {
return HistoryAttachment(
url: _asString(json['url']),
mimeType: _asString(json['mimeType']),
);
}
}
String _asString(Object? value, {String fallback = ''}) {
if (value is String) {
return value;
}
return fallback;
}
int _asInt(Object? value) {
if (value is int) {
return value;
}
if (value is double) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value) ?? 0;
}
return 0;
}
Map<String, dynamic>? _asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
final result = <String, dynamic>{};
for (final entry in value.entries) {
final key = entry.key;
if (key is String) {
result[key] = entry.value;
}
}
return result;
}
return null;
}
List<HistoryAttachment> _parseHistoryAttachments(Object? value) {
if (value is! List) {
return const <HistoryAttachment>[];
}
return value
.whereType<Map<String, dynamic>>()
.map(HistoryAttachment.fromJson)
.where(
(attachment) =>
attachment.url.isNotEmpty && attachment.mimeType.isNotEmpty,
)
.toList();
}
@@ -1,114 +0,0 @@
import 'package:social_app/data/network/i_api_client.dart';
import 'package:social_app/data/cache/cache_policy.dart';
import 'package:social_app/data/cache/cached_repository.dart';
import '../models/ag_ui_event.dart';
class ChatHistoryRepository extends CachedRepository<HistorySnapshot> {
ChatHistoryRepository({required IApiClient apiClient, required super.store})
: _apiClient = apiClient,
super(
policy: const CachePolicy(
softTtl: Duration(seconds: 30),
hardTtl: Duration(minutes: 5),
minRefreshInterval: Duration(seconds: 15),
),
encodeValue: _encodeSnapshot,
decodeValue: _decodeSnapshot,
);
final IApiClient _apiClient;
Future<HistorySnapshot> loadHistory({
String? threadId,
DateTime? beforeDate,
bool forceRefresh = false,
}) {
final key = _keyFor(threadId: threadId, beforeDate: beforeDate);
return getOrLoad(
key: key,
forceRefresh: forceRefresh,
loadFromRemote: () =>
_loadHistoryRemote(threadId: threadId, beforeDate: beforeDate),
);
}
Future<HistorySnapshot> _loadHistoryRemote({
String? threadId,
DateTime? beforeDate,
}) async {
final path = _buildHistoryPath(threadId: threadId, beforeDate: beforeDate);
final response = await _apiClient.get<Map<String, dynamic>>(path);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/history response');
}
return HistorySnapshot.fromJson(payload);
}
static String _buildHistoryPath({String? threadId, DateTime? beforeDate}) {
final query = <String>[];
if (threadId != null && threadId.isNotEmpty) {
query.add('threadId=$threadId');
}
if (beforeDate != null) {
final day = DateTime(beforeDate.year, beforeDate.month, beforeDate.day);
query.add('before=${day.toIso8601String().substring(0, 10)}');
}
if (query.isEmpty) {
return '/api/v1/agent/history';
}
return '/api/v1/agent/history?${query.join('&')}';
}
static String _keyFor({String? threadId, DateTime? beforeDate}) {
final threadPart = (threadId == null || threadId.isEmpty)
? 'default'
: threadId;
if (beforeDate == null) {
return 'chat:history:first:$threadPart';
}
final day = DateTime(
beforeDate.year,
beforeDate.month,
beforeDate.day,
).toIso8601String().substring(0, 10);
return 'chat:history:before:$threadPart:$day';
}
static Object? _encodeSnapshot(HistorySnapshot snapshot) {
return <String, Object?>{
'scope': snapshot.scope,
'threadId': snapshot.threadId,
'day': snapshot.day,
'hasMore': snapshot.hasMore,
'messages': snapshot.messages.map(_encodeMessage).toList(growable: false),
};
}
static HistorySnapshot _decodeSnapshot(Object? raw) {
if (raw is! Map) {
throw const FormatException('Invalid cached history snapshot payload');
}
return HistorySnapshot.fromJson(Map<String, dynamic>.from(raw));
}
static Map<String, Object?> _encodeMessage(HistoryMessage message) {
return <String, Object?>{
'id': message.id,
'seq': message.seq,
'role': message.role,
'content': message.content,
'timestamp': message.timestamp.toIso8601String(),
'attachments': message.attachments
.map(
(attachment) => <String, Object?>{
'url': attachment.url,
'mimeType': attachment.mimeType,
},
)
.toList(growable: false),
'ui_schema': message.uiSchema,
};
}
}
@@ -1,504 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/data/network/i_api_client.dart';
import '../models/ag_ui_event.dart';
import '../repositories/chat_history_repository.dart';
typedef EventCallback = void Function(AgUiEvent event);
const _runIdPrefix = 'run_';
class UploadedAttachment {
const UploadedAttachment({
required this.localPath,
required this.url,
required this.mimeType,
});
final String localPath;
final String url;
final String mimeType;
}
class SendMessageResult {
const SendMessageResult({required this.uploadedAttachments});
final List<UploadedAttachment> uploadedAttachments;
}
class _RunInputPayload {
const _RunInputPayload({
required this.input,
required this.uploadedAttachments,
});
final Map<String, dynamic> input;
final List<UploadedAttachment> uploadedAttachments;
}
class AgUiService {
final IApiClient _apiClient;
final ChatHistoryRepository? _historyRepository;
EventCallback onEvent;
final Map<String, String> _lastEventIdByThread = {};
int _activeStreamToken = 0;
StreamSubscription<String>? _activeSseSubscription;
Completer<void>? _activeSseDoneCompleter;
String? _threadId;
String? _activeThreadIdForRun;
String? _activeRunId;
bool _hasMoreHistory = false;
AgUiService({
EventCallback? onEvent,
required IApiClient apiClient,
ChatHistoryRepository? historyRepository,
}) : onEvent = onEvent ?? ((_) {}),
_apiClient = apiClient,
_historyRepository = historyRepository;
Future<SendMessageResult> sendMessage(
String content, {
List<XFile>? images,
}) async {
await _cancelActiveSseSubscription();
final streamToken = ++_activeStreamToken;
final runInputPayload = await _buildRunInput(
content: content,
images: images,
);
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs',
data: runInputPayload.input,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/runs response');
}
final threadId = payload['threadId'] as String?;
final runId = payload['runId'] as String?;
if (threadId == null || threadId.isEmpty) {
throw StateError('Missing threadId in /agent/runs response');
}
if (runId == null || runId.isEmpty) {
throw StateError('Missing runId in /agent/runs response');
}
_threadId = threadId;
_activeThreadIdForRun = threadId;
_activeRunId = runId;
try {
await _streamEventsFromApi(
threadId,
expectedRunId: runId,
streamToken: streamToken,
);
} finally {
if (_activeThreadIdForRun == threadId && _activeRunId == runId) {
_activeThreadIdForRun = null;
_activeRunId = null;
}
}
return SendMessageResult(
uploadedAttachments: runInputPayload.uploadedAttachments,
);
}
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
final repository = _historyRepository;
final snapshot = repository != null
? await repository.loadHistory(
threadId: _threadId,
beforeDate: beforeDate,
)
: await _loadHistoryFromApi(beforeDate: beforeDate);
if (snapshot.threadId != null && snapshot.threadId!.isNotEmpty) {
_threadId = snapshot.threadId;
}
_hasMoreHistory = snapshot.hasMore;
return snapshot;
}
Future<HistorySnapshot> _loadHistoryFromApi({DateTime? beforeDate}) async {
final path = _buildHistoryPath(beforeDate: beforeDate);
final response = await _apiClient.get<Map<String, dynamic>>(path);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/history response');
}
return HistorySnapshot.fromJson(payload);
}
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
final response = await _apiClient.get<List<int>>(
previewPath,
options: Options(responseType: ResponseType.bytes),
);
final payload = response.data;
if (payload is List<int>) {
return Uint8List.fromList(payload);
}
throw StateError('Invalid attachment payload');
}
Future<String> transcribeAudio(String filePath) async {
final formData = FormData.fromMap({
'audio': await MultipartFile.fromFile(
filePath,
filename: 'recording.wav',
contentType: DioMediaType('audio', 'wav'),
),
});
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/transcribe',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/transcribe response');
}
final transcript = payload['transcript'];
if (transcript is! String) {
throw StateError('Missing transcript in /agent/transcribe response');
}
return transcript;
}
bool hasEarlierHistory(DateTime fromDate) {
// Whether earlier history exists is driven by backend snapshot.hasMore.
// Keep the parameter for compatibility with the current ChatBloc signature.
final _ = fromDate;
return _hasMoreHistory;
}
Future<void> cancelCurrentRun() async {
final activeThreadId = _activeThreadIdForRun;
final activeRunId = _activeRunId;
if (activeThreadId != null && activeRunId != null) {
final encodedRunId = Uri.encodeQueryComponent(activeRunId);
await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs/$activeThreadId/cancel?runId=$encodedRunId',
);
_activeThreadIdForRun = null;
_activeRunId = null;
_activeStreamToken += 1;
await _cancelActiveSseSubscription();
return;
}
_activeStreamToken += 1;
await _cancelActiveSseSubscription();
}
Future<void> _cancelActiveSseSubscription() async {
final doneCompleter = _activeSseDoneCompleter;
if (doneCompleter != null && !doneCompleter.isCompleted) {
doneCompleter.complete();
}
_activeSseDoneCompleter = null;
final subscription = _activeSseSubscription;
_activeSseSubscription = null;
if (subscription == null) {
return;
}
await subscription.cancel();
}
Future<void> _streamEventsFromApi(
String threadId, {
required String expectedRunId,
required int streamToken,
}) async {
final lastEventId = _lastEventIdByThread[threadId];
final headers = <String, String>{'Accept': 'text/event-stream'};
if (lastEventId != null && lastEventId.isNotEmpty) {
headers['Last-Event-ID'] = lastEventId;
}
final sseLines = await _apiClient.getSseLines(
'/api/v1/agent/runs/$threadId/events',
headers: headers,
);
String? eventType;
String? eventId;
var hasBoundExpectedRun = false;
var hasSeenTerminalForRun = false;
final dataBuffer = StringBuffer();
final done = Completer<void>();
late final StreamSubscription<String> subscription;
void stopStream({Object? error, StackTrace? stackTrace}) {
if (!done.isCompleted) {
if (error == null) {
done.complete();
} else {
done.completeError(error, stackTrace);
}
}
unawaited(subscription.cancel());
}
subscription = sseLines.listen(
(line) {
try {
if (streamToken != _activeStreamToken) {
stopStream();
return;
}
if (line.isEmpty) {
if (dataBuffer.isNotEmpty) {
final raw = dataBuffer.toString();
dataBuffer.clear();
String? eventRunId;
String? eventThreadId;
Map<String, dynamic>? parsedMap;
try {
final parsed = jsonDecode(raw);
if (parsed is Map<String, dynamic>) {
parsedMap = parsed;
}
} catch (_) {
// Ignore malformed SSE payload and keep stream alive.
}
if (parsedMap != null) {
final runId = parsedMap['runId'];
final thread = parsedMap['threadId'];
eventRunId = runId is String ? runId : null;
eventThreadId = thread is String ? thread : null;
final isRunStarted = eventType == AgUiEventTypeWire.runStarted;
final isTargetRun = eventRunId == expectedRunId;
if (isRunStarted && isTargetRun) {
hasBoundExpectedRun = true;
}
final isThreadMatched =
eventThreadId == null || eventThreadId == threadId;
final shouldDispatch =
isTargetRun || (hasBoundExpectedRun && isThreadMatched);
if (shouldDispatch) {
final event = AgUiEvent.fromJson(parsedMap);
onEvent(event);
}
}
final currentEventId = eventId;
if (currentEventId != null && currentEventId.isNotEmpty) {
_lastEventIdByThread[threadId] = currentEventId;
}
final isTerminalEvent =
eventType == AgUiEventTypeWire.runFinished ||
eventType == AgUiEventTypeWire.runError;
final isTargetRun = eventRunId == expectedRunId;
final isThreadMatched =
eventThreadId == null || eventThreadId == threadId;
if (isTerminalEvent &&
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
hasSeenTerminalForRun = true;
stopStream();
return;
}
}
eventType = null;
eventId = null;
return;
}
if (line.startsWith(':')) {
return;
}
if (line.startsWith('id:')) {
eventId = line.substring(3).trim();
return;
}
if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
return;
}
if (line.startsWith('data:')) {
final fragment = line.substring(5).trim();
if (dataBuffer.isNotEmpty) {
dataBuffer.write('\n');
}
dataBuffer.write(fragment);
}
} catch (error, stackTrace) {
stopStream(error: error, stackTrace: stackTrace);
}
},
onError: (Object error, StackTrace stackTrace) {
stopStream(error: error, stackTrace: stackTrace);
},
onDone: () {
if (streamToken != _activeStreamToken) {
stopStream();
return;
}
if (!hasSeenTerminalForRun) {
stopStream(
error: StateError('SSE closed before terminal event for run'),
);
return;
}
stopStream();
},
cancelOnError: false,
);
if (streamToken != _activeStreamToken) {
await subscription.cancel();
return;
}
_activeSseSubscription = subscription;
_activeSseDoneCompleter = done;
try {
await done.future;
} finally {
if (identical(_activeSseSubscription, subscription)) {
_activeSseSubscription = null;
}
if (identical(_activeSseDoneCompleter, done)) {
_activeSseDoneCompleter = null;
}
}
}
Future<_RunInputPayload> _buildRunInput({
required String content,
List<XFile>? images,
}) async {
final threadId = _threadId ?? _newUuid();
final runId = _nextId(_runIdPrefix);
final contentBlocks = <Map<String, dynamic>>[];
if (content.isNotEmpty) {
contentBlocks.add({'type': 'text', 'text': content});
}
var uploadedAttachments = const <UploadedAttachment>[];
if (images != null && images.isNotEmpty) {
uploadedAttachments = await _uploadAttachments(
threadId: threadId,
images: images,
);
for (final attachment in uploadedAttachments) {
contentBlocks.add({
'type': 'binary',
'mimeType': attachment.mimeType,
'url': attachment.url,
});
}
}
final dynamic messageContent;
if (contentBlocks.isEmpty) {
messageContent = '';
} else if (contentBlocks.length == 1 &&
contentBlocks[0]['type'] == 'text') {
messageContent = contentBlocks[0]['text'];
} else {
messageContent = contentBlocks;
}
return _RunInputPayload(
input: {
'threadId': threadId,
'runId': runId,
'state': <String, dynamic>{},
'messages': [
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
],
'tools': <Map<String, dynamic>>[],
'context': <Map<String, dynamic>>[],
'forwardedProps': <String, dynamic>{'runtime_mode': 'chat'},
},
uploadedAttachments: uploadedAttachments,
);
}
Future<List<UploadedAttachment>> _uploadAttachments({
required String threadId,
required List<XFile> images,
}) async {
final attachments = <UploadedAttachment>[];
for (final image in images) {
final mimeType = image.mimeType ?? 'image/jpeg';
final fileBytes = await image.readAsBytes();
final formData = FormData.fromMap({
'threadId': threadId,
'file': MultipartFile.fromBytes(
fileBytes,
filename: image.name,
contentType: DioMediaType.parse(mimeType),
),
});
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/attachments',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/attachments response');
}
final attachment = payload['attachment'];
if (attachment is! Map<String, dynamic>) {
throw StateError('Missing attachment in /agent/attachments response');
}
final bucket = attachment['bucket'];
final path = attachment['path'];
final uploadedMime = attachment['mimeType'];
final url = attachment['url'];
if (bucket is! String ||
path is! String ||
uploadedMime is! String ||
url is! String ||
bucket.isEmpty ||
path.isEmpty ||
uploadedMime.isEmpty ||
url.isEmpty) {
throw StateError('Invalid attachment reference');
}
attachments.add(
UploadedAttachment(
localPath: image.path,
url: url,
mimeType: uploadedMime,
),
);
}
return attachments;
}
String _buildHistoryPath({DateTime? beforeDate}) {
final query = <String>[];
if (_threadId != null && _threadId!.isNotEmpty) {
query.add('threadId=$_threadId');
}
if (beforeDate != null) {
final day = DateTime(beforeDate.year, beforeDate.month, beforeDate.day);
query.add('before=${day.toIso8601String().substring(0, 10)}');
}
if (query.isEmpty) {
return '/api/v1/agent/history';
}
return '/api/v1/agent/history?${query.join('&')}';
}
String _nextId(String prefix) =>
'$prefix${DateTime.now().millisecondsSinceEpoch}';
String _newUuid() {
final random = Random();
String hex(int len) => List<String>.generate(
len,
(_) => random.nextInt(16).toRadixString(16),
).join();
const variant = ['8', '9', 'a', 'b'];
return '${hex(8)}-${hex(4)}-4${hex(3)}-${variant[random.nextInt(4)]}${hex(3)}-${hex(12)}';
}
}
@@ -1,5 +1,5 @@
import '../../../../core/l10n/l10n.dart';
import '../../data/models/ag_ui_event.dart';
import '../../../../core/chat/ag_ui_event.dart';
String agUiEventLabel(AgUiEventType type) {
final l10n = L10n.current;
@@ -2,16 +2,15 @@ import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/chat/chat_api.dart';
import 'package:social_app/core/chat/agent_stage.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_list_item.dart';
import 'package:social_app/core/chat/chat_orchestrator.dart';
import 'package:social_app/data/network/i_api_client.dart';
import 'package:social_app/core/chat/chat_history_repository.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../data/models/ag_ui_event.dart';
import '../../data/repositories/chat_history_repository.dart';
import '../../data/services/ag_ui_service.dart';
class ChatState implements ChatOrchestratorState {
@override
final List<ChatListItem> items;
@@ -98,14 +97,11 @@ class ChatState implements ChatOrchestratorState {
class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
ChatBloc({
AgUiService? service,
required IApiClient apiClient,
required ChatApi chatApi,
ChatHistoryRepository? historyRepository,
}) : _service =
service ??
AgUiService(
apiClient: apiClient,
historyRepository: historyRepository,
),
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
super(const ChatState()) {
_service.onEvent = _handleEvent;
}
@@ -434,7 +430,20 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
),
);
try {
final sendResult = await _service.sendMessage(content, images: images);
final uploadInputs = await Future.wait(
(images ?? const <XFile>[]).map(
(image) async => AttachmentUploadInput(
name: image.name,
mimeType: image.mimeType ?? 'image/jpeg',
bytes: await image.readAsBytes(),
localPath: image.path,
),
),
);
final sendResult = await _service.sendMessage(
content,
attachments: uploadInputs,
);
_syncUploadedAttachments(
messageId: messageId,
uploadedAttachments: sendResult.uploadedAttachments,
@@ -393,12 +393,6 @@ class _HomeScreenState extends State<HomeScreen>
try {
if (hasEarlierHistory) {
await _loadMoreHistoryPreservingViewport(chatBloc);
} else {
Toast.show(
context,
context.l10n.homeNoEarlierHistory,
type: ToastType.info,
);
}
_applyViewportDecision(
_dispatchViewportEvent(
@@ -687,29 +681,6 @@ class _HomeEmptyStateAmbient extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: IgnorePointer(
child: Container(
key: homeEmptyStateAmbientKey,
width: double.infinity,
height: AppSpacing.xxl * 6,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
colorScheme.primaryContainer.withValues(alpha: 0.12),
colorScheme.primary.withValues(alpha: 0.08),
colorScheme.primaryContainer.withValues(alpha: 0.12),
],
),
),
),
),
);
return const SizedBox.shrink(key: homeEmptyStateAmbientKey);
}
}
@@ -1,10 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
const homeBackgroundFieldKey = ValueKey('home_background_field');
const homeTopGlowKey = ValueKey('home_top_glow');
const homeBottomGlowKey = ValueKey('home_bottom_glow');
class HomeBackgroundField extends StatelessWidget {
const HomeBackgroundField({super.key});
@@ -22,72 +18,7 @@ class HomeBackgroundField extends StatelessWidget {
colors: [colorScheme.surface, colorScheme.surfaceContainerLowest],
),
),
child: const Stack(children: [_HomeTopGlow(), _HomeBottomGlow()]),
);
}
}
class _HomeTopGlow extends StatelessWidget {
const _HomeTopGlow();
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Align(
alignment: const Alignment(-0.25, -0.9),
child: IgnorePointer(
child: Container(
key: homeTopGlowKey,
width: AppSpacing.xxl * 10,
height: AppSpacing.xxl * 7,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppSpacing.xxl * 3),
color: colorScheme.primaryContainer.withValues(alpha: 0.28),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.2),
blurRadius: AppSpacing.xxl * 3,
spreadRadius: AppSpacing.xl,
),
],
),
),
),
);
}
}
class _HomeBottomGlow extends StatelessWidget {
const _HomeBottomGlow();
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return IgnorePointer(
child: Align(
alignment: Alignment.bottomCenter,
child: Transform.translate(
offset: const Offset(0, AppSpacing.lg),
child: Container(
key: homeBottomGlowKey,
width: AppSpacing.xxl * 12,
height: AppSpacing.xxl * 3,
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.1),
blurRadius: AppSpacing.xxl,
spreadRadius: AppSpacing.sm,
),
],
),
),
),
),
child: const SizedBox.expand(),
);
}
}