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
+596
View File
@@ -0,0 +1,596 @@
import "dart:convert";
import "package:flutter/foundation.dart";
import "../api/openrouter_client.dart";
import "../compact/compact_service.dart";
import "../hooks/hook_runner.dart";
import "../hooks/hook_types.dart";
import "../local_state.dart";
import "../permissions/permission_types.dart";
import "../services/cost_tracker.dart" as cost_tracker;
import "../chat/tool_loop_service.dart";
import "conversation_history.dart";
import "session_store.dart";
import "session_types.dart";
// All the mutable state that belongs to a single chat thread.
// Previously this was all crammed onto ChatProvider, which meant switching
// threads would clobber in-flight state from the previous thread.
//
// A SessionRuntime is created when a session is activated and kept alive
// as long as there might be work still running (isLoading || isCompacting).
// ChatProvider holds a Map<sessionId, SessionRuntime> and switches which
// one is "active" when the user changes threads — the background ones keep
// running and save themselves to disk when done.
class SessionRuntime {
SessionRuntime({
required ConversationSession session,
required ToolLoopService toolLoopService,
required HookRunner? hookRunner,
required LocalSettings Function() getSettings,
required String Function(String?) normalizeModelId,
required VoidCallback onChanged,
}) : _toolLoopService = toolLoopService,
_hookRunner = hookRunner,
_getSettings = getSettings,
_normalizeModelId = normalizeModelId,
_onChanged = onChanged {
_conversationHistory = ConversationHistory(session: session);
_apiMessages = _buildApiMessages(session.messages);
// restore persisted per-thread mode override
_permissionModeOverride = session.permissionMode;
}
final ToolLoopService _toolLoopService;
final HookRunner? _hookRunner;
final VoidCallback _onChanged;
final LocalSettings Function() _getSettings;
final String Function(String?) _normalizeModelId;
late final ConversationHistory _conversationHistory;
late List<Map<String, dynamic>> _apiMessages;
OpenRouterClient? _client;
bool _isLoading = false;
bool _isCompacting = false;
bool _stopRequested = false;
PendingPermission? _pendingPermission;
final List<QueuedMessage> _messageQueue = [];
// per-thread permission mode override (null = use global setting)
String? _permissionModeOverride;
String get permissionModeOverride =>
_permissionModeOverride ?? _getSettings().permissionMode ?? "default";
Future<void> setPermissionModeOverride(String mode) async {
_permissionModeOverride = mode;
final session = _conversationHistory.session;
if (session != null) {
session.permissionMode = mode;
await SessionStore.instance.saveSession(session);
}
_onChanged();
}
// set when a turn finishes while the user is viewing a different thread
bool _hasUnreadResult = false;
// compact state
String? _lastCompactSummary;
bool _suppressCompactWarning = false;
int _consecutiveCompactFailures = 0;
static const int _maxConsecutiveCompactFailures = 3;
// ─── read-only accessors ────────────────────────────────────────────────────
String get sessionId => _conversationHistory.session?.id ?? "";
List<Message> get messages => _conversationHistory.getMessages();
int get messageCount => messages.length;
String? get workingDirectory =>
_conversationHistory.session?.workingDirectory;
bool get isLoading => _isLoading;
bool get isCompacting => _isCompacting;
bool get isStopping => _stopRequested;
PendingPermission? get pendingPermission => _pendingPermission;
bool get hasUnreadResult => _hasUnreadResult;
void markRead() {
if (!_hasUnreadResult) return;
_hasUnreadResult = false;
_onChanged();
}
int get queuedMessageCount => _messageQueue.length;
List<String> get queuedMessages =>
List.unmodifiable(_messageQueue.map((m) => m.text));
String? get lastCompactSummary => _lastCompactSummary;
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;
}
TokenWarningState? get tokenWarningState {
final ct = contextTokens;
if (ct <= 0) return null;
final model = _getSettings().model ?? "";
final state = calculateTokenWarningState(ct, model);
if (_suppressCompactWarning && !state.isClean) {
return TokenWarningState(
percentLeft: state.percentLeft,
isAboveWarningThreshold: false,
isAboveErrorThreshold: false,
isAboveAutoCompactThreshold: false,
isAtBlockingLimit: false,
);
}
return state;
}
// ─── message queue ──────────────────────────────────────────────────────────
void removeQueuedMessage(int index) {
if (index < 0 || index >= _messageQueue.length) return;
_messageQueue.removeAt(index);
_onChanged();
}
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;
}
// ─── send message ───────────────────────────────────────────────────────────
Future<void> sendMessage(
String text, {
QueuePriority priority = QueuePriority.next,
List<AttachmentData>? attachments,
}) async {
_hasUnreadResult = false;
final hasAttachments = attachments != null && attachments.isNotEmpty;
if (text.isEmpty && !hasAttachments) return;
// intercept /compact
final trimmed = text.trim();
if (trimmed.startsWith("/compact")) {
final custom = trimmed.length > 8 ? trimmed.substring(8).trim() : null;
await runCompact(customInstructions: custom?.isEmpty == true ? null : custom);
return;
}
if (_isLoading) {
_messageQueue.add(QueuedMessage(text: text, priority: priority));
_onChanged();
return;
}
final settings = _getSettings();
final apiKey = settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
throw Exception("OpenRouter API key not set.");
}
final model = _normalizeModelId(settings.model);
try {
_stopRequested = false;
bool hasStreamingAssistantMessage = false;
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
final session = _conversationHistory.session;
if (session != null) {
session.model = model;
if (session.name == "New Chat") {
session.name = _buildSessionName(text);
}
}
await _hookRunner?.runHooksForKind(
HookKind.userPromptSubmit,
input: {"message": text},
);
// build attachment blocks
final List<Map<String, dynamic>> attachmentBlocks = [];
if (attachments != null) {
for (final att in attachments) {
if (att.isImage) {
final dataUrl =
"data:${att.mimeType};base64,${base64Encode(att.data)}";
attachmentBlocks.add(<String, dynamic>{
"type": "image_url",
"image_url": <String, dynamic>{"url": dataUrl},
});
} else {
final decoded = utf8.decode(att.data, allowMalformed: true);
attachmentBlocks.add(<String, dynamic>{
"type": "text",
"text": "File: ${att.name}\n\n$decoded",
});
}
}
}
final msgAttachments = attachments
?.map((a) => MessageAttachment(
name: a.name,
mimeType: a.mimeType,
data: a.data,
))
.toList();
_conversationHistory.addMessage(
"user",
text,
attachments: msgAttachments,
);
_isLoading = true;
_onChanged();
final toolLoopResult = await _toolLoopService.runTurn(
client: _client!,
model: model,
apiKey: apiKey,
getSettings: () {
var base = _getSettings();
// apply thread-level permission mode override if set
if (_permissionModeOverride != null) {
base = base.copyWith(permissionMode: _permissionModeOverride);
}
final sessionRules = _conversationHistory.session?.alwaysAllowRules ?? [];
if (sessionRules.isEmpty) return base;
final merged = base.alwaysAllowRules.toSet()..addAll(sessionRules);
return base.copyWith(alwaysAllowRules: merged.toList());
},
apiMessages: _apiMessages,
userText: text,
attachmentBlocks: attachmentBlocks.isEmpty ? null : attachmentBlocks,
workingDirectory: workingDirectory,
advisorModel: _getSettings().advisorModel,
onToolCall: (toolName, input) {
_conversationHistory.addMessage(
"tool",
_formatToolCall(toolName, input),
);
_onChanged();
},
onToolResult: (toolName, result) {
_conversationHistory.addMessage(
"tool",
_formatToolResult(toolName, result),
);
_onChanged();
},
onAssistantTextDelta: (delta) {
if (!hasStreamingAssistantMessage) {
_conversationHistory.addMessage("assistant", "");
hasStreamingAssistantMessage = true;
}
_conversationHistory.appendToLastMessage(delta);
_onChanged();
},
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
_onChanged();
},
onPermissionRequired: (toolName, input, {String? suggestionRule}) async {
final pending = PendingPermission(
toolName: toolName,
input: input,
suggestionRule: suggestionRule,
);
_pendingPermission = pending;
_onChanged();
final decision = await pending.future;
_pendingPermission = null;
_onChanged();
return decision;
},
shouldStop: () => _stopRequested,
);
_apiMessages = toolLoopResult.apiMessages;
// time-based microcompact
final mcResult = applyTimeBasedMicrocompact(_apiMessages);
if (mcResult != null) _apiMessages = mcResult;
final ct = toolLoopResult.response.contextTokens;
if (!toolLoopResult.finalResponseWasStreamed) {
_conversationHistory.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
contextTokens: ct,
);
} else {
_conversationHistory.setLastMessageContextTokens(ct);
}
cost_tracker.addToTotalSessionCost(
cost: 0.0,
inputTokens: toolLoopResult.response.inputTokens ?? 0,
outputTokens: toolLoopResult.response.outputTokens ?? 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
// auto-compact
if (ct > 0) {
final warning = calculateTokenWarningState(ct, model);
if (warning.isAboveAutoCompactThreshold &&
_consecutiveCompactFailures < _maxConsecutiveCompactFailures &&
_client != null) {
try {
_suppressCompactWarning = false;
await _runCompactInternal(
client: _client!,
model: model,
suppressFollowUpQuestions: true,
);
} catch (e) {
_consecutiveCompactFailures++;
print("[compact] auto-compact failed: $e");
}
}
}
if (session != null) {
await SessionStore.instance.saveSession(session);
}
_onChanged();
} catch (error, stackTrace) {
print("SessionRuntime.sendMessage failed: $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",
"This turn failed before the assistant could finish: $error",
);
final session = _conversationHistory.session;
if (session != null) {
await SessionStore.instance.saveSession(session);
}
rethrow;
} finally {
_client?.close();
_client = null;
_stopRequested = false;
_isLoading = false;
_hasUnreadResult = true;
_onChanged();
}
final next = _dequeue();
if (next != null) {
_onChanged();
await sendMessage(next.text, priority: next.priority);
}
}
// ─── stop ───────────────────────────────────────────────────────────────────
void stopGenerating() {
if (!_isLoading) return;
_pendingPermission?.resolve(PermissionDecision.reject);
_pendingPermission = null;
_messageQueue.clear();
_stopRequested = true;
_client?.cancelActiveRequest();
_onChanged();
_hookRunner?.runHooksForKind(HookKind.stop);
}
// ─── compact ────────────────────────────────────────────────────────────────
Future<void> runCompact({String? customInstructions}) async {
if (_apiMessages.isEmpty) return;
if (_isLoading || _isCompacting) return;
final settings = _getSettings();
final apiKey = settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) return;
final model = _normalizeModelId(settings.model);
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
try {
_isCompacting = true;
_suppressCompactWarning = false;
_onChanged();
await _runCompactInternal(
client: client,
model: model,
customInstructions: customInstructions,
suppressFollowUpQuestions: false,
);
} catch (e) {
print("[compact] manual compact failed: $e");
_conversationHistory.addMessage("assistant", "Compaction failed: $e");
_onChanged();
} finally {
client.close();
_isCompacting = false;
_onChanged();
}
}
Future<void> _runCompactInternal({
required OpenRouterClient client,
required String model,
String? customInstructions,
bool suppressFollowUpQuestions = false,
}) async {
final result = await compactConversation(
client: client,
model: model,
apiMessages: _apiMessages,
customInstructions: customInstructions,
suppressFollowUpQuestions: suppressFollowUpQuestions,
);
_apiMessages = result.messages;
_lastCompactSummary = result.summaryText;
_suppressCompactWarning = true;
_consecutiveCompactFailures = 0;
_conversationHistory.addMessage(
"assistant",
"✦ Conversation compacted (${result.preCompactMessageCount} messages → summary). "
"Context has been reset.",
);
_onChanged();
}
// ─── permission ─────────────────────────────────────────────────────────────
Future<void> resolvePermission(PermissionDecision decision) async {
final pending = _pendingPermission;
if (pending == null) return;
if (decision == PermissionDecision.allowAlways) {
final session = _conversationHistory.session;
if (session != null) {
final rule = _buildRuleString(pending.toolName, pending.input);
if (!session.alwaysAllowRules.contains(rule)) {
session.alwaysAllowRules.add(rule);
await SessionStore.instance.saveSession(session);
}
}
}
pending.resolve(decision);
_pendingPermission = null;
_onChanged();
}
// ─── dispose ────────────────────────────────────────────────────────────────
void dispose() {
_client?.close();
_client = null;
}
// ─── helpers ────────────────────────────────────────────────────────────────
List<Map<String, dynamic>> _buildApiMessages(List<Message> messages) {
return messages
.where((m) => m.role == "user" || m.role == "assistant")
.map((m) => <String, dynamic>{"role": m.role, "content": m.content})
.toList(growable: true);
}
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()}";
}
String _formatToolCall(String toolName, Map<String, dynamic> input) {
const encoder = JsonEncoder.withIndent(" ");
final visibleInput = Map<String, dynamic>.fromEntries(
input.entries.where((e) => !e.key.startsWith("_")),
);
return "$toolName call\n${encoder.convert(visibleInput)}";
}
String _formatToolResult(String toolName, String result) {
return "$toolName result\n$result";
}
String _buildRuleString(String toolName, Map<String, dynamic> input) {
String? content;
if (toolName == "Bash") {
content = input["command"] as String?;
} else if (toolName == "Read" || toolName == "Write" || toolName == "Edit") {
content = input["file_path"] as String?;
} else if (toolName == "Glob" || toolName == "Grep") {
content = input["pattern"] as String?;
} else if (toolName == "WebFetch" || toolName == "WebSearch") {
content = input["url"] as String? ?? input["query"] as String?;
}
if (content == null || content.isEmpty) return toolName;
return "$toolName($content)";
}
}
// Small data classes used by SessionRuntime that were previously on ChatProvider
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});
}
class AttachmentData {
final String name;
final String mimeType;
final List<int> data;
const AttachmentData({required this.name, required this.mimeType, required this.data});
bool get isImage => mimeType.startsWith("image/");
}