Files
qzl d060962a5f feat(agent): redesign project_cli with module/method/input protocol
- Replace command/subcommand/args with module/method/input envelope
- Calendar handler uses discriminated union (mode) for read operations
- Strict Pydantic models with extra='forbid' for all calendar methods
- Worker max_iters=7, router prompt simplified (removed project_cli_defaults)
- Skill index cards + per-action files for progressive disclosure
- Frontend/AG-UI aligned to module/method dispatch
- Protocol docs updated to module/method/input contract

WIP: action cards need envelope fix, 2 tests need update, memory
handler needs Pydantic models.
2026-04-24 13:24:13 +08:00

330 lines
9.4 KiB
Dart

import 'dart:async';
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/logging/logger.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/core/chat/chat_history_repository.dart';
import 'package:social_app/core/chat/chat_timeline_reconciler.dart';
import 'package:social_app/core/l10n/l10n.dart';
import 'chat_bloc_recovery_utils.dart';
part 'chat_bloc_events.dart';
part 'chat_bloc_send.dart';
part 'chat_bloc_history.dart';
part 'chat_bloc_attachments.dart';
typedef ChatCompletedCallback =
void Function({
required String conversationId,
required int messageCount,
required int responseTimeMs,
});
class ChatState implements ChatOrchestratorState {
@override
final List<ChatListItem> items;
@override
final bool isSending;
@override
final bool isWaitingFirstToken;
@override
final bool isStreaming;
@override
final bool isCancelling;
@override
final bool isLoadingHistory;
@override
final String? currentMessageId;
@override
final String? error;
@override
final DateTime? oldestLoadedDate;
@override
final bool hasEarlierHistory;
@override
final AgentStage? currentStage;
final bool hasSeenStep;
const ChatState({
this.items = const [],
this.isSending = false,
this.isWaitingFirstToken = false,
this.isStreaming = false,
this.isCancelling = false,
this.isLoadingHistory = false,
this.currentMessageId,
this.error,
this.oldestLoadedDate,
this.hasEarlierHistory = false,
this.currentStage,
this.hasSeenStep = false,
});
@override
bool get isLoading =>
isSending ||
isWaitingFirstToken ||
isStreaming ||
isCancelling ||
isLoadingHistory;
static const _unset = Object();
ChatState copyWith({
List<ChatListItem>? items,
bool? isSending,
bool? isWaitingFirstToken,
bool? isStreaming,
bool? isCancelling,
bool? isLoadingHistory,
Object? currentMessageId = _unset,
Object? error = _unset,
Object? oldestLoadedDate = _unset,
bool? hasEarlierHistory,
Object? currentStage = _unset,
bool? hasSeenStep,
}) {
return ChatState(
items: items ?? this.items,
isSending: isSending ?? this.isSending,
isWaitingFirstToken: isWaitingFirstToken ?? this.isWaitingFirstToken,
isStreaming: isStreaming ?? this.isStreaming,
isCancelling: isCancelling ?? this.isCancelling,
isLoadingHistory: isLoadingHistory ?? this.isLoadingHistory,
currentMessageId: currentMessageId == _unset
? this.currentMessageId
: currentMessageId as String?,
error: error == _unset ? this.error : error as String?,
oldestLoadedDate: oldestLoadedDate == _unset
? this.oldestLoadedDate
: oldestLoadedDate as DateTime?,
hasEarlierHistory: hasEarlierHistory ?? this.hasEarlierHistory,
currentStage: currentStage == _unset
? this.currentStage
: currentStage as AgentStage?,
hasSeenStep: hasSeenStep ?? this.hasSeenStep,
);
}
}
class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
final Logger _logger = getLogger('features.chat.bloc');
ChatBloc({
AgUiService? service,
required ChatApi chatApi,
ChatHistoryRepository? historyRepository,
Future<void> Function()? onCalendarMutated,
ChatCompletedCallback? onChatCompleted,
Duration recoveryPollInterval = const Duration(milliseconds: 700),
Duration recoveryTimeout = const Duration(seconds: 20),
}) : _service =
service ??
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
_onCalendarMutated = onCalendarMutated,
_onChatCompleted = onChatCompleted,
_recoveryPollInterval = recoveryPollInterval,
_recoveryTimeout = recoveryTimeout,
super(const ChatState()) {
_service.onEvent = _handleEvent;
}
final AgUiService _service;
final Future<void> Function()? _onCalendarMutated;
final ChatCompletedCallback? _onChatCompleted;
final Duration _recoveryPollInterval;
final Duration _recoveryTimeout;
String? _activeUserId;
DateTime? _activeRunStartedAt;
DateTime? _activeRunFirstResponseAt;
String? _activeRunId;
String? _activeThreadId;
int _sessionEpoch = 0;
final Map<String, Uint8List> _attachmentPreviewCache = <String, Uint8List>{};
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
<String, Future<Uint8List?>>{};
/// Common state reset for run completion (success/error/cancel)
ChatState _resetRunState({String? error, String? currentMessageId}) {
return state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: currentMessageId,
error: error,
currentStage: null,
hasSeenStep: false,
);
}
@override
Future<void> sendMessage(String content, {List<XFile>? images}) {
return _sendMessage(content, images: images);
}
@override
Future<void> loadHistory() {
return _loadHistory();
}
@override
Future<void> loadMoreHistory() {
return _loadMoreHistory();
}
@override
Future<String> transcribeAudioFile(String filePath) {
return _service.transcribeAudio(filePath);
}
@override
Future<bool> cancelCurrentRun() async {
if (!(state.isWaitingFirstToken ||
state.isStreaming ||
state.isCancelling)) {
return false;
}
emit(state.copyWith(isCancelling: true, error: null));
try {
await _service.cancelCurrentRun();
emit(
state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: null,
),
);
return true;
} catch (error) {
emit(state.copyWith(isCancelling: false, error: error.toString()));
return false;
}
}
@override
void clearError() {
emit(state.copyWith(error: null));
}
Future<Uint8List?> loadAttachmentPreview(String previewPath) {
return _loadAttachmentPreview(previewPath);
}
Future<void> switchUser(String? userId) async {
final normalizedUserId = userId?.trim();
if (_activeUserId == normalizedUserId) {
return;
}
final epoch = ++_sessionEpoch;
_activeUserId = normalizedUserId;
try {
await _service.setUserContext(normalizedUserId);
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to set user context',
error: e,
stackTrace: stackTrace,
extra: {'user_id': normalizedUserId},
);
}
if (epoch != _sessionEpoch) {
return;
}
_attachmentPreviewCache.clear();
_attachmentPreviewInflight.clear();
emit(const ChatState());
if (normalizedUserId != null && epoch == _sessionEpoch) {
try {
await _loadHistory();
} catch (error) {
emit(state.copyWith(error: error.toString()));
}
}
}
bool _shouldRefreshCalendarForTool(ToolCallResultEvent event) {
final name = event.toolName.trim().toLowerCase();
final status = event.status.trim().toLowerCase();
if (name != 'project_cli') {
return false;
}
final args = event.toolCallArgs;
if (args == null) {
return false;
}
final skill = (args['skill'] as String?)?.trim().toLowerCase();
final action = (args['action'] as String?)?.trim().toLowerCase();
const mutationActions = {'create_event', 'update_event', 'delete_event'};
if (skill != 'calendar' || !mutationActions.contains(action)) {
return false;
}
return status == 'success' || status == 'partial';
}
Future<void> _refreshCalendarAfterToolMutation() async {
final callback = _onCalendarMutated;
if (callback == null) {
return;
}
try {
await callback();
} catch (error) {
emit(state.copyWith(error: error.toString()));
}
}
void _recordRunStarted({required String runId, required String threadId}) {
_activeRunStartedAt = DateTime.now();
_activeRunFirstResponseAt = null;
_activeRunId = runId;
_activeThreadId = threadId;
}
void _recordRunFirstResponse() {
_activeRunFirstResponseAt ??= DateTime.now();
}
void _trackChatCompleted() {
final startedAt = _activeRunStartedAt;
if (startedAt == null) {
return;
}
final firstResponseAt = _activeRunFirstResponseAt ?? DateTime.now();
final responseTimeMs = firstResponseAt.difference(startedAt).inMilliseconds;
final threadId = _activeThreadId?.trim();
final runId = _activeRunId?.trim();
final conversationId = (threadId != null && threadId.isNotEmpty)
? threadId
: runId;
if (conversationId == null || conversationId.isEmpty) {
return;
}
final onChatCompleted = _onChatCompleted;
if (onChatCompleted != null) {
onChatCompleted(
conversationId: conversationId,
messageCount: 1,
responseTimeMs: responseTimeMs,
);
}
}
void _clearRunMetrics() {
_activeRunStartedAt = null;
_activeRunFirstResponseAt = null;
_activeRunId = null;
_activeThreadId = null;
}
}