228 lines
6.8 KiB
Dart
228 lines
6.8 KiB
Dart
import "package:flutter/foundation.dart";
|
|
import "dart:convert";
|
|
|
|
import "../../src/chat/tool_loop_service.dart";
|
|
import "../../src/api/openrouter_client.dart";
|
|
import "../../src/session/conversation_history.dart";
|
|
import "../../src/session/session_store.dart";
|
|
import "../../src/session/session_types.dart";
|
|
import "../../src/services/cost_tracker.dart" as cost_tracker;
|
|
import "settings_provider.dart";
|
|
|
|
class ChatProvider extends ChangeNotifier {
|
|
ChatProvider(this._settingsProvider);
|
|
|
|
final SettingsProvider _settingsProvider;
|
|
final ToolLoopService _toolLoopService = ToolLoopService();
|
|
ConversationHistory? _conversationHistory;
|
|
OpenRouterClient? _client;
|
|
bool _stopRequested = false;
|
|
|
|
List<Message> _messages = <Message>[];
|
|
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
|
|
bool isLoading = false;
|
|
|
|
List<Message> get messages => _messages;
|
|
int get messageCount => _messages.length;
|
|
bool get hasConversation => _conversationHistory != null;
|
|
bool get isStopping => _stopRequested;
|
|
|
|
void setConversation(ConversationHistory history) {
|
|
_conversationHistory = history;
|
|
_messages = history.getMessages();
|
|
_apiMessages = _buildApiMessages(_messages);
|
|
notifyListeners();
|
|
}
|
|
|
|
void clearConversation() {
|
|
_conversationHistory = null;
|
|
_messages = <Message>[];
|
|
_apiMessages = <Map<String, dynamic>>[];
|
|
isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> sendMessage(String text) async {
|
|
if (text.isEmpty || _conversationHistory == null) 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;
|
|
_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);
|
|
}
|
|
}
|
|
|
|
// add user message to conversation
|
|
_conversationHistory!.addMessage("user", text);
|
|
_messages = _conversationHistory!.getMessages();
|
|
_apiMessages.add(<String, dynamic>{"role": "user", "content": text});
|
|
isLoading = true;
|
|
notifyListeners();
|
|
|
|
final toolLoopResult = await _toolLoopService.runTurn(
|
|
client: _client!,
|
|
model: model,
|
|
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
|
|
userText: text,
|
|
workingDirectory: workingDirectory,
|
|
onToolCall: (toolName, input) {
|
|
_conversationHistory!.addMessage(
|
|
"tool",
|
|
_formatToolCall(toolName, input),
|
|
);
|
|
_messages = _conversationHistory!.getMessages();
|
|
notifyListeners();
|
|
},
|
|
onToolResult: (toolName, result) {
|
|
_conversationHistory!.addMessage(
|
|
"tool",
|
|
_formatToolResult(toolName, result),
|
|
);
|
|
_messages = _conversationHistory!.getMessages();
|
|
notifyListeners();
|
|
},
|
|
);
|
|
_apiMessages = toolLoopResult.apiMessages;
|
|
|
|
// add assistant message to visible conversation
|
|
_conversationHistory!.addMessage(
|
|
"assistant",
|
|
toolLoopResult.responseText,
|
|
tokens: toolLoopResult.response.outputTokens,
|
|
);
|
|
_messages = _conversationHistory!.getMessages();
|
|
|
|
// 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,
|
|
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;
|
|
_messages = _conversationHistory!.getMessages();
|
|
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;
|
|
_messages = _conversationHistory!.getMessages();
|
|
if (session != null) {
|
|
await SessionStore.instance.saveSession(session);
|
|
}
|
|
rethrow;
|
|
} finally {
|
|
_client?.close();
|
|
_client = null;
|
|
_stopRequested = false;
|
|
isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void stopGenerating() {
|
|
if (!isLoading) {
|
|
return;
|
|
}
|
|
|
|
_stopRequested = true;
|
|
print("Stopping active turn");
|
|
_client?.cancelActiveRequest();
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_client?.close();
|
|
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(" ");
|
|
return "$toolName call\n${encoder.convert(input)}";
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|