Update project structure and enhance functionality with new features and dependencies

This commit is contained in:
ImBenji
2026-04-14 03:31:29 +01:00
parent 0b6b604c56
commit 3588783001
63 changed files with 10565 additions and 789 deletions
+122 -319
View File
@@ -1,49 +1,38 @@
import "package:flutter/foundation.dart";
import "dart:convert";
import "dart:typed_data";
import "../../src/chat/tool_loop_service.dart";
import "../../src/api/openrouter_client.dart";
import "../../src/compact/compact_service.dart";
import "../../src/hooks/hook_loader.dart";
import "../../src/hooks/hook_runner.dart";
import "../../src/hooks/hook_types.dart";
import "../../src/permissions/permission_types.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_runtime.dart";
import "../../src/session/session_types.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
import "../models/attachment.dart";
import "settings_provider.dart";
enum QueuePriority {
now(0),
next(1),
later(2);
final int order;
const QueuePriority(this.order);
}
class QueuedMessage {
final String text;
final QueuePriority priority;
const QueuedMessage({required this.text, required this.priority});
}
// 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) {
_initHooks();
}
final SettingsProvider _settingsProvider;
ToolLoopService _toolLoopService = ToolLoopService();
HookRunner? _hookRunner;
ConversationHistory? _conversationHistory;
OpenRouterClient? _client;
bool _stopRequested = false;
PendingPermission? _pendingPermission;
PendingPermission? get pendingPermission => _pendingPermission;
final Map<String, SessionRuntime> _runtimes = {};
String? _activeSessionId;
// ─── hooks ──────────────────────────────────────────────────────────────────
Future<void> _initHooks() async {
try {
@@ -51,22 +40,48 @@ class ChatProvider extends ChangeNotifier {
_hookRunner = HookRunner(hooks: hooks);
_toolLoopService = ToolLoopService(hookRunner: _hookRunner);
} catch (e) {
// hooks are optional, carry on without them
print("Hook init failed: $e");
}
}
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
bool isLoading = false;
final List<QueuedMessage> _messageQueue = [];
// ─── active runtime accessors ────────────────────────────────────────────────
List<Message> get messages => _conversationHistory?.getMessages() ?? const [];
SessionRuntime? get _active =>
_activeSessionId != null ? _runtimes[_activeSessionId] : null;
List<Message> get messages => _active?.messages ?? const [];
int get messageCount => messages.length;
String? get workingDirectory => _conversationHistory?.session?.workingDirectory;
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;
}
/// Context window size from the last API response — derived from persisted
/// message data, same as Claude Code (walks backwards to find the last
/// assistant message that has contextTokens set).
int get contextTokens {
final msgs = messages;
for (var i = msgs.length - 1; i >= 0; i--) {
@@ -75,304 +90,92 @@ class ChatProvider extends ChangeNotifier {
}
return 0;
}
bool get hasConversation => _conversationHistory != null;
bool get isStopping => _stopRequested;
int get queuedMessageCount => _messageQueue.length;
// only user-visible messages (priority != now)
List<String> get queuedMessages =>
List.unmodifiable(_messageQueue.map((m) => m.text));
// ─── session lifecycle ───────────────────────────────────────────────────────
void removeQueuedMessage(int index) {
if (index < 0 || index >= _messageQueue.length) return;
_messageQueue.removeAt(index);
notifyListeners();
}
// 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;
QueuedMessage? _dequeue() {
if (_messageQueue.isEmpty) return null;
int bestIdx = 0;
for (int i = 1; i < _messageQueue.length; i++) {
if (_messageQueue[i].priority.order < _messageQueue[bestIdx].priority.order) {
bestIdx = i;
}
}
final cmd = _messageQueue[bestIdx];
_messageQueue.removeAt(bestIdx);
return cmd;
}
void setConversation(ConversationHistory history) {
_conversationHistory = history;
_apiMessages = _buildApiMessages(history.getMessages());
notifyListeners();
}
void clearConversation() {
_conversationHistory = null;
_apiMessages = <Map<String, dynamic>>[];
_messageQueue.clear();
isLoading = false;
notifyListeners();
}
Future<void> sendMessage(String text, {QueuePriority priority = QueuePriority.next}) async {
if (text.isEmpty || _conversationHistory == null) return;
if (isLoading) {
_messageQueue.add(QueuedMessage(text: text, priority: priority));
notifyListeners();
return;
}
final apiKey = _settingsProvider.settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
throw Exception(
"OpenRouter API key not set. Please configure it in settings.",
);
}
final savedModel = _settingsProvider.settings.model;
final model = _settingsProvider.normalizeModelId(savedModel);
if (savedModel != model) {
print("Normalizing legacy model ID from $savedModel to $model");
await _settingsProvider.updateModel(model);
}
try {
_stopRequested = false;
bool hasStreamingAssistantMessage = false;
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
final session = _conversationHistory!.session;
final workingDirectory = session?.workingDirectory;
if (session != null) {
session.model = model;
if (session.name == "New Chat") {
session.name = _buildSessionName(text);
}
}
// fire UserPromptSubmit hook
await _hookRunner?.runHooksForKind(
HookKind.userPromptSubmit,
input: {"message": text},
);
// add user message to conversation
_conversationHistory!.addMessage("user", text);
_apiMessages.add(<String, dynamic>{"role": "user", "content": text});
isLoading = true;
notifyListeners();
final advisorModel = _settingsProvider.settings.advisorModel;
final toolLoopResult = await _toolLoopService.runTurn(
client: _client!,
model: model,
apiKey: apiKey,
if (!_runtimes.containsKey(id)) {
_runtimes[id] = SessionRuntime(
session: session,
toolLoopService: _toolLoopService,
hookRunner: _hookRunner,
getSettings: () => _settingsProvider.settings,
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
userText: text,
workingDirectory: workingDirectory,
advisorModel: advisorModel,
onToolCall: (toolName, input) {
_conversationHistory!.addMessage(
"tool",
_formatToolCall(toolName, input),
);
notifyListeners();
},
onToolResult: (toolName, result) {
_conversationHistory!.addMessage(
"tool",
_formatToolResult(toolName, result),
);
notifyListeners();
},
onAssistantTextDelta: (delta) {
if (!hasStreamingAssistantMessage) {
_conversationHistory!.addMessage("assistant", "");
hasStreamingAssistantMessage = true;
}
_conversationHistory!.appendToLastMessage(delta);
notifyListeners();
},
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
notifyListeners();
},
onPermissionRequired: (toolName, input) async {
final pending = PendingPermission(toolName: toolName, input: input);
_pendingPermission = pending;
notifyListeners();
final decision = await pending.future;
_pendingPermission = null;
notifyListeners();
return decision;
},
normalizeModelId: (m) => _settingsProvider.normalizeModelId(m),
onChanged: notifyListeners,
);
_apiMessages = toolLoopResult.apiMessages;
final ct = toolLoopResult.response.contextTokens;
// add assistant message to visible conversation
if (!toolLoopResult.finalResponseWasStreamed) {
_conversationHistory!.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
contextTokens: ct,
);
} else {
// streamed message was built incrementally — patch contextTokens onto it
_conversationHistory!.setLastMessageContextTokens(ct);
}
// track cost (set to 0 for now — OpenRouter pricing varies by model)
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
final outputTokens = toolLoopResult.response.outputTokens ?? 0;
cost_tracker.addToTotalSessionCost(
cost: 0.0,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
// save session
if (session != null) {
await SessionStore.instance.saveSession(session);
}
notifyListeners();
} catch (error, stackTrace) {
print("Failed to send message: $error");
print(stackTrace);
if (error is RequestCancelledException) {
_conversationHistory!.addMessage("assistant", "Generation stopped.");
final session = _conversationHistory!.session;
if (session != null) {
await SessionStore.instance.saveSession(session);
}
return;
}
if (error is ToolLoopException) {
_apiMessages = List<Map<String, dynamic>>.from(error.apiMessages);
}
_conversationHistory!.addMessage(
"assistant",
_buildTurnFailureMessage(error),
);
final session = _conversationHistory!.session;
if (session != null) {
await SessionStore.instance.saveSession(session);
}
rethrow;
} finally {
_client?.close();
_client = null;
_stopRequested = false;
isLoading = false;
notifyListeners();
}
final next = _dequeue();
if (next != null) {
notifyListeners();
await sendMessage(next.text, priority: next.priority);
}
}
void resolvePermission(PermissionDecision decision) async {
final pending = _pendingPermission;
if (pending == null) return;
if (decision == PermissionDecision.allowAlways) {
// persist to settings so this tool is auto-allowed from now on
await _settingsProvider.addAlwaysAllowRule(pending.toolName);
}
pending.resolve(decision);
_pendingPermission = null;
_activeSessionId = id;
_runtimes[id]?.markRead();
notifyListeners();
}
void stopGenerating() {
if (!isLoading) {
return;
// 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();
}
_pendingPermission?.resolve(PermissionDecision.reject);
_pendingPermission = null;
_messageQueue.clear();
_stopRequested = true;
print("Stopping active turn");
_client?.cancelActiveRequest();
notifyListeners();
_hookRunner?.runHooksForKind(HookKind.stop);
}
// 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) =>
_active?.resolvePermission(decision) ?? Future.value();
void removeQueuedMessage(int index) => _active?.removeQueuedMessage(index);
// ─── dispose ────────────────────────────────────────────────────────────────
@override
void dispose() {
_client?.close();
for (final r in _runtimes.values) {
r.dispose();
}
super.dispose();
}
String _buildSessionName(String text) {
final sanitized = text.replaceAll(RegExp(r"\s+"), " ").trim();
if (sanitized.isEmpty) {
return "New Chat";
}
const maxLength = 48;
if (sanitized.length <= maxLength) {
return sanitized;
}
return "${sanitized.substring(0, maxLength - 1).trimRight()}";
}
List<Map<String, dynamic>> _buildApiMessages(List<Message> messages) {
return messages
.where(
(message) => message.role == "user" || message.role == "assistant",
)
.map(
(message) => <String, dynamic>{
"role": message.role,
"content": message.content,
},
)
.toList(growable: true);
}
String _formatToolCall(String toolName, Map<String, dynamic> input) {
const encoder = JsonEncoder.withIndent(" ");
final visibleInput = Map<String, dynamic>.fromEntries(
input.entries.where((entry) => !entry.key.startsWith("_")),
);
return "$toolName call\n${encoder.convert(visibleInput)}";
}
String _formatToolResult(String toolName, String result) {
return "$toolName result\n$result";
}
String _buildTurnFailureMessage(Object error) {
return "This turn failed before the assistant could finish: $error";
}
}
+28 -13
View File
@@ -4,6 +4,7 @@ import "package:flutter/foundation.dart";
import "../../src/project_store.dart";
import "../../src/session/session_types.dart";
import "chat_provider.dart";
import "../models/attachment.dart";
import "projects_provider.dart";
import "session_provider.dart";
import "settings_provider.dart";
@@ -56,20 +57,17 @@ class HomeCoordinator extends ChangeNotifier {
}
}
Future<void> createNewChat() async {
void createNewChat() {
final selectedProject = _projects.selectedProject;
if (selectedProject == null) {
_setError("Choose a project first so the new chat has a working directory.");
return;
}
await _session.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
// Don't create the session yet — that happens on first message send.
// Just clear the current state so the UI shows a blank chat.
_session.clearCurrentSession(workingDirectory: selectedProject.workingDirectory);
_chat.clearConversation();
}
Future<void> selectProject(ProjectRecord project) async {
@@ -84,14 +82,28 @@ class HomeCoordinator extends ChangeNotifier {
}
Future<void> openSession(SessionSummary session) async {
// If a live runtime exists for this session, just switch focus to it
// without reloading from disk — avoids disrupting an in-progress turn.
if (_chat.isSessionRunning(session.id)) {
_chat.activateSessionById(session.id);
_session.setActiveSessionId(session.id);
_projects.selectProjectByWorkingDirectory(session.workingDirectory);
_settings.setThreadModel(session.model);
return;
}
await _session.loadSession(session);
_chat.setConversation(_session.getConversationHistory());
final loaded = _session.currentSession;
if (loaded != null) {
_chat.activateSession(loaded);
}
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
_settings.setThreadModel(_session.currentSession?.model);
}
Future<void> sendMessage(String text) async {
if (text.isEmpty) return;
Future<void> sendMessage(String text, {List<Attachment>? attachments}) async {
final hasAttachments = attachments != null && attachments.isNotEmpty;
if (text.isEmpty && !hasAttachments) return;
if (_session.currentSession == null) {
final selectedProject = _projects.selectedProject;
@@ -105,11 +117,14 @@ class HomeCoordinator extends ChangeNotifier {
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
final newSession = _session.currentSession;
if (newSession != null) {
_chat.activateSession(newSession);
}
}
try {
await _chat.sendMessage(text);
await _chat.sendMessage(text, attachments: attachments);
} catch (e, st) {
print("Failed to send message: $e");
print(st);
+5
View File
@@ -25,6 +25,11 @@ class SessionProvider extends ChangeNotifier {
ConversationSession? get currentSession => _currentSession;
String? get activeWorkingDirectory => _activeWorkingDirectory;
void setActiveSessionId(String id) {
_currentSessionId = id;
notifyListeners();
}
List<SessionSummary> sessionsForWorkingDirectory(String? workingDirectory) {
final normalizedDirectory = workingDirectory?.trim();
if (normalizedDirectory == null || normalizedDirectory.isEmpty) {
+8
View File
@@ -90,6 +90,14 @@ class SettingsProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> updatePermissionMode(String mode) async {
await _settingsStore.update(
(current) => current.copyWith(permissionMode: mode),
);
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> addAlwaysAllowRule(String toolName) async {
final current = _globalSettings.alwaysAllowRules;
if (current.contains(toolName)) return;