Update project structure and enhance functionality with new features and dependencies
This commit is contained in:
@@ -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/");
|
||||
}
|
||||
Reference in New Issue
Block a user