The-Agency/lib/ui/providers/chat_provider.dart

245 lines
7.4 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;
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);
}
}
// 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();
},
onAssistantTextDelta: (delta) {
if (!hasStreamingAssistantMessage) {
_conversationHistory!.addMessage("assistant", "");
hasStreamingAssistantMessage = true;
}
_conversationHistory!.appendToLastMessage(delta);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
);
_apiMessages = toolLoopResult.apiMessages;
// add assistant message to visible conversation
if (!toolLoopResult.finalResponseWasStreamed) {
_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";
}
}