The-Agency/lib/ui/providers/chat_provider.dart

233 lines
8.1 KiB
Dart

import "package:flutter/foundation.dart";
import "package:flutter/scheduler.dart";
import "dart:typed_data";
import "../../src/chat/tool_loop_service.dart";
import "../../src/compact/compact_service.dart";
import "../../src/hooks/hook_loader.dart";
import "../../src/hooks/hook_runner.dart";
import "../../src/permissions/permission_types.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
import "../../src/session/session_runtime.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
import "../models/attachment.dart";
import "cost_provider.dart";
import "settings_provider.dart";
// ChatProvider is now a thin registry over SessionRuntime instances.
//
// Each thread gets its own SessionRuntime which holds all the mutable state
// that used to live here — api messages, the http client, loading flags, etc.
// Switching threads just changes _activeSessionId. Background threads keep
// running and save themselves to disk; when you switch back you see their
// live state.
class ChatProvider extends ChangeNotifier {
ChatProvider(this._settingsProvider, this._costProvider) {
_initHooks();
}
final SettingsProvider _settingsProvider;
final CostProvider _costProvider;
void Function(String sessionId, String newName)? onSessionNameChanged;
ToolLoopService _toolLoopService = ToolLoopService();
HookRunner? _hookRunner;
final Map<String, SessionRuntime> _runtimes = {};
final Map<String, ConversationSession> _sessions = {};
String? _activeSessionId;
bool _notifyScheduled = false;
void _scheduleNotify() {
if (_notifyScheduled) return;
_notifyScheduled = true;
SchedulerBinding.instance.scheduleFrameCallback((_) {
_notifyScheduled = false;
notifyListeners();
_costProvider.refreshCost();
});
}
// ─── hooks ──────────────────────────────────────────────────────────────────
Future<void> _initHooks() async {
try {
final hooks = await HookLoader.loadHooks();
_hookRunner = HookRunner(hooks: hooks);
_toolLoopService = ToolLoopService(hookRunner: _hookRunner);
} catch (e) {
print("Hook init failed: $e");
}
}
// ─── active runtime accessors ────────────────────────────────────────────────
SessionRuntime? get _active =>
_activeSessionId != null ? _runtimes[_activeSessionId] : null;
List<Message> get messages => _active?.messages ?? const [];
int get messageCount => messages.length;
String? get workingDirectory => _active?.workingDirectory;
bool get hasConversation => _active != null;
bool get isLoading => _active?.isLoading ?? false;
bool get isCompacting => _active?.isCompacting ?? false;
bool get isStopping => _active?.isStopping ?? false;
int get queuedMessageCount => _active?.queuedMessageCount ?? 0;
List<String> get queuedMessages => _active?.queuedMessages ?? const [];
PendingPermission? get pendingPermission => _active?.pendingPermission;
String? get lastCompactSummary => _active?.lastCompactSummary;
TokenWarningState? get tokenWarningState => _active?.tokenWarningState;
String get threadPermissionMode => _active?.permissionModeOverride ?? "default";
Future<void> setThreadPermissionMode(String mode) =>
_active?.setPermissionModeOverride(mode) ?? Future.value();
bool isSessionRunning(String sessionId) {
final r = _runtimes[sessionId];
return r != null && (r.isLoading || r.isCompacting);
}
bool sessionNeedsAttention(String sessionId) {
final r = _runtimes[sessionId];
return r != null && r.pendingPermission != null;
}
bool sessionHasUnreadResult(String sessionId) {
final r = _runtimes[sessionId];
return r != null && r.hasUnreadResult;
}
void markSessionRead(String sessionId) {
_runtimes[sessionId]?.setUnreadResult(false);
}
int get contextTokens {
final msgs = messages;
for (var i = msgs.length - 1; i >= 0; i--) {
final ct = msgs[i].contextTokens;
if (ct != null && ct > 0) return ct;
}
return 0;
}
// ─── session lifecycle ───────────────────────────────────────────────────────
// Called when the user switches to (or creates) a session.
// Creates a new runtime if one doesn't already exist for this session.
void activateSession(ConversationSession session) {
final id = session.id;
if (!_runtimes.containsKey(id)) {
_sessions[id] = session;
_runtimes[id] = SessionRuntime(
session: session,
toolLoopService: _toolLoopService,
hookRunner: _hookRunner,
getSettings: () => _settingsProvider.settings,
normalizeModelId: (m) => _settingsProvider.normalizeModelId(m),
onChanged: _scheduleNotify,
onCostAdded: (_) {
final s = _sessions[id];
if (s != null) SessionStore.instance.saveSession(s);
},
isActive: () => _activeSessionId == id,
onNameGenerated: (sid, name) {
onSessionNameChanged?.call(sid, name);
notifyListeners();
},
onPersistAllowRule: (rule) => _settingsProvider.addAlwaysAllowRule(rule),
);
}
_activeSessionId = id;
_runtimes[id]?.markRead();
// sync global cost tracker to this thread's persisted cost
cost_tracker.resetCostState();
final sessionCost = (_sessions[id] ?? session).cost;
if (sessionCost > 0) {
cost_tracker.setCostStateForRestore(
totalCostUsd: sessionCost,
totalApiDurationMs: 0,
totalApiDurationWithoutRetriesMs: 0,
totalToolDurationMs: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
);
}
notifyListeners();
_costProvider.refreshCost();
}
// Fast-path: switch focus to an already-running runtime without touching disk.
void activateSessionById(String sessionId) {
if (_runtimes.containsKey(sessionId)) {
_activeSessionId = sessionId;
_runtimes[sessionId]?.markRead();
notifyListeners();
}
}
// Called when the user starts a new blank chat — no session exists yet.
void clearConversation() {
_activeSessionId = null;
// prune dead runtimes that are done
_runtimes.removeWhere((_, r) => !r.isLoading && !r.isCompacting);
notifyListeners();
}
// Legacy compat — kept so HomeCoordinator doesn't need parallel changes
// for paths that still call this. Routes to activateSession.
void setConversation(ConversationSession session) => activateSession(session);
// ─── actions — delegate to active runtime ───────────────────────────────────
Future<void> sendMessage(
String text, {
QueuePriority priority = QueuePriority.next,
List<Attachment>? attachments,
}) async {
final runtime = _active;
if (runtime == null) return;
final adapted = attachments
?.map((a) => AttachmentData(
name: a.name,
mimeType: a.mimeType,
data: a.data,
))
.toList();
await runtime.sendMessage(text, priority: priority, attachments: adapted);
}
void stopGenerating() => _active?.stopGenerating();
Future<void> runCompact({String? customInstructions}) =>
_active?.runCompact(customInstructions: customInstructions) ??
Future.value();
Future<void> resolvePermission(PermissionDecision decision, {String? persistRule}) =>
_active?.resolvePermission(decision, persistRule: persistRule) ?? Future.value();
void removeQueuedMessage(int index) => _active?.removeQueuedMessage(index);
// ─── dispose ────────────────────────────────────────────────────────────────
@override
void dispose() {
for (final r in _runtimes.values) {
r.dispose();
}
super.dispose();
}
}