Add command files and enhance session management features
This commit is contained in:
@@ -3,6 +3,7 @@ import "dart:convert";
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "../api/openrouter_client.dart";
|
||||
import "../api/response_parser.dart";
|
||||
import "../compact/compact_service.dart";
|
||||
import "../hooks/hook_runner.dart";
|
||||
import "../hooks/hook_types.dart";
|
||||
@@ -32,11 +33,19 @@ class SessionRuntime {
|
||||
required LocalSettings Function() getSettings,
|
||||
required String Function(String?) normalizeModelId,
|
||||
required VoidCallback onChanged,
|
||||
void Function(double costDelta)? onCostAdded,
|
||||
bool Function()? isActive,
|
||||
void Function(String sessionId, String name)? onNameGenerated,
|
||||
Future<void> Function(String rule)? onPersistAllowRule,
|
||||
}) : _toolLoopService = toolLoopService,
|
||||
_hookRunner = hookRunner,
|
||||
_getSettings = getSettings,
|
||||
_normalizeModelId = normalizeModelId,
|
||||
_onChanged = onChanged {
|
||||
_onChanged = onChanged,
|
||||
_onCostAdded = onCostAdded,
|
||||
_isActive = isActive,
|
||||
_onNameGenerated = onNameGenerated,
|
||||
_onPersistAllowRule = onPersistAllowRule {
|
||||
_conversationHistory = ConversationHistory(session: session);
|
||||
_apiMessages = _buildApiMessages(session.messages);
|
||||
// restore persisted per-thread mode override
|
||||
@@ -46,8 +55,14 @@ class SessionRuntime {
|
||||
final ToolLoopService _toolLoopService;
|
||||
final HookRunner? _hookRunner;
|
||||
final VoidCallback _onChanged;
|
||||
final void Function(double costDelta)? _onCostAdded;
|
||||
final LocalSettings Function() _getSettings;
|
||||
final String Function(String?) _normalizeModelId;
|
||||
final bool Function()? _isActive;
|
||||
final void Function(String sessionId, String name)? _onNameGenerated;
|
||||
final Future<void> Function(String rule)? _onPersistAllowRule;
|
||||
|
||||
bool _nameGenerated = false;
|
||||
|
||||
late final ConversationHistory _conversationHistory;
|
||||
late List<Map<String, dynamic>> _apiMessages;
|
||||
@@ -79,6 +94,10 @@ class SessionRuntime {
|
||||
// set when a turn finishes while the user is viewing a different thread
|
||||
bool _hasUnreadResult = false;
|
||||
|
||||
// true while a streaming tool is actively pushing chunks — prevents onToolResult
|
||||
// from double-adding the content that was already appended chunk by chunk
|
||||
bool _streamingToolOutput = false;
|
||||
|
||||
// compact state
|
||||
String? _lastCompactSummary;
|
||||
bool _suppressCompactWarning = false;
|
||||
@@ -102,6 +121,11 @@ class SessionRuntime {
|
||||
PendingPermission? get pendingPermission => _pendingPermission;
|
||||
bool get hasUnreadResult => _hasUnreadResult;
|
||||
|
||||
void setUnreadResult(bool value) {
|
||||
_hasUnreadResult = value;
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
void markRead() {
|
||||
if (!_hasUnreadResult) return;
|
||||
_hasUnreadResult = false;
|
||||
@@ -204,6 +228,9 @@ class SessionRuntime {
|
||||
bool hasStreamingAssistantMessage = false;
|
||||
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
|
||||
|
||||
// detect first turn before the user message is added
|
||||
final bool isFirstTurn = _apiMessages.isEmpty && !_nameGenerated;
|
||||
|
||||
final session = _conversationHistory.session;
|
||||
if (session != null) {
|
||||
session.model = model;
|
||||
@@ -282,13 +309,28 @@ class SessionRuntime {
|
||||
);
|
||||
_onChanged();
|
||||
},
|
||||
onToolResult: (toolName, result) {
|
||||
_conversationHistory.addMessage(
|
||||
"tool",
|
||||
_formatToolResult(toolName, result),
|
||||
);
|
||||
onToolOutputChunk: (toolName, chunk) {
|
||||
// append live chunk to the last tool message (which onToolCall just added)
|
||||
_streamingToolOutput = true;
|
||||
_conversationHistory.appendToLastMessage(chunk);
|
||||
_onChanged();
|
||||
},
|
||||
onToolResult: (toolName, result) {
|
||||
if (_streamingToolOutput) {
|
||||
// content already in the message from live chunks — dont double-add
|
||||
_streamingToolOutput = false;
|
||||
} else {
|
||||
_conversationHistory.addMessage(
|
||||
"tool",
|
||||
_formatToolResult(toolName, result),
|
||||
);
|
||||
}
|
||||
_onChanged();
|
||||
|
||||
// save after each tool result so progress isnt lost if app dies mid-turn
|
||||
final s = _conversationHistory.session;
|
||||
if (s != null) SessionStore.instance.saveSession(s);
|
||||
},
|
||||
onAssistantTextDelta: (delta) {
|
||||
if (!hasStreamingAssistantMessage) {
|
||||
_conversationHistory.addMessage("assistant", "");
|
||||
@@ -300,6 +342,10 @@ class SessionRuntime {
|
||||
onAssistantMessageComplete: () {
|
||||
hasStreamingAssistantMessage = false;
|
||||
_onChanged();
|
||||
|
||||
// save after each complete assistant message (streaming done)
|
||||
final s = _conversationHistory.session;
|
||||
if (s != null) SessionStore.instance.saveSession(s);
|
||||
},
|
||||
onPermissionRequired: (toolName, input, {String? suggestionRule}) async {
|
||||
final pending = PendingPermission(
|
||||
@@ -325,27 +371,58 @@ class SessionRuntime {
|
||||
|
||||
final ct = toolLoopResult.response.contextTokens;
|
||||
|
||||
final rawUsage = toolLoopResult.response.usage;
|
||||
final responseCost = (rawUsage?["cost"] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
double advisorCostTotal = 0;
|
||||
for (final au in toolLoopResult.advisorUsages) {
|
||||
cost_tracker.addToTotalSessionCost(
|
||||
cost: au.costUsd,
|
||||
inputTokens: au.inputTokens,
|
||||
outputTokens: au.outputTokens,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
model: au.model,
|
||||
);
|
||||
advisorCostTotal += au.costUsd;
|
||||
}
|
||||
|
||||
final totalCostThisTurn = responseCost + advisorCostTotal;
|
||||
|
||||
cost_tracker.addToTotalSessionCost(
|
||||
cost: responseCost,
|
||||
inputTokens: toolLoopResult.response.inputTokens ?? 0,
|
||||
outputTokens: toolLoopResult.response.outputTokens ?? 0,
|
||||
cacheReadTokens: toolLoopResult.response.cacheReadInputTokens ?? 0,
|
||||
cacheCreationTokens: toolLoopResult.response.cacheCreationInputTokens ?? 0,
|
||||
webSearchRequests: toolLoopResult.webSearchRequests,
|
||||
webFetchRequests: toolLoopResult.webFetchRequests,
|
||||
model: toolLoopResult.response.model,
|
||||
);
|
||||
|
||||
if (!toolLoopResult.finalResponseWasStreamed) {
|
||||
_conversationHistory.addMessage(
|
||||
"assistant",
|
||||
toolLoopResult.responseText,
|
||||
tokens: toolLoopResult.response.outputTokens,
|
||||
contextTokens: ct,
|
||||
cost: totalCostThisTurn > 0 ? totalCostThisTurn : null,
|
||||
);
|
||||
} else {
|
||||
_conversationHistory.setLastMessageContextTokens(ct);
|
||||
_conversationHistory.setLastMessageCost(
|
||||
totalCostThisTurn > 0 ? totalCostThisTurn : null,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
if (totalCostThisTurn > 0) _onCostAdded?.call(totalCostThisTurn);
|
||||
_onChanged();
|
||||
|
||||
// generate an AI name for the thread after the first turn
|
||||
if (isFirstTurn && session != null && _onNameGenerated != null) {
|
||||
_nameGenerated = true;
|
||||
_generateThreadName(session, text, apiKey, model);
|
||||
}
|
||||
|
||||
// auto-compact
|
||||
if (ct > 0) {
|
||||
@@ -405,7 +482,9 @@ class SessionRuntime {
|
||||
_client = null;
|
||||
_stopRequested = false;
|
||||
_isLoading = false;
|
||||
_hasUnreadResult = true;
|
||||
if (_conversationHistory.session?.id != null && !(_isActive?.call() ?? false)) {
|
||||
_hasUnreadResult = true;
|
||||
}
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
@@ -485,30 +564,58 @@ class SessionRuntime {
|
||||
_suppressCompactWarning = true;
|
||||
_consecutiveCompactFailures = 0;
|
||||
|
||||
// store a boundary marker so that on session restore we know where to
|
||||
// cut the history for the api call. content = the summary string the
|
||||
// model will see; full message history before this marker is kept for
|
||||
// the user to scroll back through.
|
||||
_conversationHistory.addMessage("compact_boundary", result.messages.first["content"] as String);
|
||||
_conversationHistory.addMessage(
|
||||
"assistant",
|
||||
"✦ Conversation compacted (${result.preCompactMessageCount} messages → summary). "
|
||||
"Context has been reset.",
|
||||
);
|
||||
|
||||
final session = _conversationHistory.session;
|
||||
if (session != null) {
|
||||
await SessionStore.instance.saveSession(session);
|
||||
}
|
||||
|
||||
// re-name the thread using the compact summary
|
||||
if (session != null && _onNameGenerated != null) {
|
||||
final settings = _getSettings();
|
||||
final apiKey = settings.openRouterApiKey;
|
||||
final model = _normalizeModelId(settings.model);
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
_generateThreadName(session, result.summaryText, apiKey, model);
|
||||
}
|
||||
}
|
||||
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
// ─── permission ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> resolvePermission(PermissionDecision decision) async {
|
||||
Future<void> resolvePermission(PermissionDecision decision, {String? persistRule}) 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);
|
||||
|
||||
if (persistRule != null && _onPersistAllowRule != null) {
|
||||
// persist to localSettings — survives session switches
|
||||
await _onPersistAllowRule!(persistRule);
|
||||
} else {
|
||||
// session-scoped only (file tools)
|
||||
final session = _conversationHistory.session;
|
||||
if (session != null) {
|
||||
final rule = pending.suggestionRule ?? _buildRuleString(pending.toolName, pending.input);
|
||||
if (!session.alwaysAllowRules.contains(rule)) {
|
||||
session.alwaysAllowRules.add(rule);
|
||||
await SessionStore.instance.saveSession(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pending.resolve(decision);
|
||||
@@ -526,10 +633,77 @@ class SessionRuntime {
|
||||
// ─── 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);
|
||||
// find the last compact boundary (if any) — everything before it belongs
|
||||
// to the old pre-compact history that the model shouldnt see again.
|
||||
// the boundary's content is the summary string we send as the first user msg.
|
||||
int lastBoundary = -1;
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role == "compact_boundary") {
|
||||
lastBoundary = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastBoundary == -1) {
|
||||
// no compaction yet — send everything
|
||||
return messages
|
||||
.where((m) => m.role == "user" || m.role == "assistant")
|
||||
.map((m) => <String, dynamic>{"role": m.role, "content": m.content})
|
||||
.toList(growable: true);
|
||||
}
|
||||
|
||||
// start with the summary as a user message, then all user/assistant
|
||||
// messages that came after the boundary
|
||||
final result = <Map<String, dynamic>>[
|
||||
{"role": "user", "content": messages[lastBoundary].content},
|
||||
];
|
||||
|
||||
for (var i = lastBoundary + 1; i < messages.length; i++) {
|
||||
final m = messages[i];
|
||||
if (m.role == "user" || m.role == "assistant") {
|
||||
result.add({"role": m.role, "content": m.content});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// fires async — does not block the caller. context is a short snippet
|
||||
// (first user msg or compact summary) — NOT the full conversation history.
|
||||
void _generateThreadName(ConversationSession session, String context, String apiKey, String model) {
|
||||
() async {
|
||||
try {
|
||||
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
|
||||
|
||||
try {
|
||||
final snippet = context.length > 600 ? "${context.substring(0, 600)}..." : context;
|
||||
final resp = await client.createMessage(
|
||||
model: model,
|
||||
maxTokens: 20,
|
||||
messages: [
|
||||
{"role": "user", "content": snippet},
|
||||
],
|
||||
system: "Generate a very short title (3-6 words) for this conversation. Reply with ONLY the title text — no quotes, no period at the end, nothing else.",
|
||||
temperature: 0.3,
|
||||
);
|
||||
|
||||
final name = ResponseParser.extractTextContent(resp)
|
||||
.replaceAll(RegExp(r'["\n\r`]'), "")
|
||||
.trim();
|
||||
|
||||
if (name.isEmpty || name.length > 80) return;
|
||||
|
||||
session.name = name;
|
||||
await SessionStore.instance.saveSession(session);
|
||||
_onNameGenerated?.call(session.id, name);
|
||||
_onChanged();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
print("[thread name] generation failed: $e");
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
String _buildSessionName(String text) {
|
||||
|
||||
Reference in New Issue
Block a user