Add initial project files and configurations for clawd_code
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "../../src/services/cost_tracker.dart" as cost_tracker;
|
||||
|
||||
class CostProvider extends ChangeNotifier {
|
||||
CostProvider();
|
||||
|
||||
double getTotalCostUsd() => cost_tracker.getTotalCostUsd();
|
||||
int getTotalInputTokens() => cost_tracker.getTotalInputTokens();
|
||||
int getTotalOutputTokens() => cost_tracker.getTotalOutputTokens();
|
||||
|
||||
String getFormattedTotalCost() => cost_tracker.formatTotalCost();
|
||||
|
||||
Future<void> refreshCost() async {
|
||||
// read current values from cost tracker
|
||||
final _ = cost_tracker.getTotalCostUsd();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:path/path.dart" as path;
|
||||
import "package:uuid/uuid.dart";
|
||||
|
||||
import "../../src/project_store.dart";
|
||||
|
||||
class ProjectsProvider extends ChangeNotifier {
|
||||
ProjectsProvider(this._projectStore) {
|
||||
_projects = List<ProjectRecord>.from(_projectStore.projects);
|
||||
}
|
||||
|
||||
final ProjectStore _projectStore;
|
||||
|
||||
List<ProjectRecord> _projects = <ProjectRecord>[];
|
||||
String? _selectedProjectId;
|
||||
|
||||
List<ProjectRecord> get projects =>
|
||||
List<ProjectRecord>.unmodifiable(_projects);
|
||||
String? get selectedProjectId => _selectedProjectId;
|
||||
ProjectRecord? get selectedProject {
|
||||
final selectedId = _selectedProjectId;
|
||||
if (selectedId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final project in _projects) {
|
||||
if (project.id == selectedId) {
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<ProjectRecord?> addProject(String workingDirectory) async {
|
||||
final normalizedDirectory = workingDirectory.trim();
|
||||
if (normalizedDirectory.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final directory = Directory(normalizedDirectory);
|
||||
if (!await directory.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final existing = _projects.cast<ProjectRecord?>().firstWhere(
|
||||
(project) => project?.workingDirectory == normalizedDirectory,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (existing != null) {
|
||||
_selectedProjectId = existing.id;
|
||||
notifyListeners();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const uuid = Uuid();
|
||||
final project = ProjectRecord(
|
||||
id: uuid.v4(),
|
||||
name: _projectNameForDirectory(normalizedDirectory),
|
||||
workingDirectory: normalizedDirectory,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
|
||||
await _projectStore.update(
|
||||
(current) => <ProjectRecord>[project, ...current],
|
||||
);
|
||||
|
||||
_projects = List<ProjectRecord>.from(_projectStore.projects);
|
||||
_selectedProjectId = project.id;
|
||||
notifyListeners();
|
||||
return project;
|
||||
}
|
||||
|
||||
void selectProject(String id) {
|
||||
if (_selectedProjectId == id) {
|
||||
return;
|
||||
}
|
||||
|
||||
final exists = _projects.any((project) => project.id == id);
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedProjectId = id;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectProjectByWorkingDirectory(String? workingDirectory) {
|
||||
final normalizedDirectory = workingDirectory?.trim();
|
||||
if (normalizedDirectory == null || normalizedDirectory.isEmpty) {
|
||||
if (_selectedProjectId != null) {
|
||||
_selectedProjectId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final match = _projects.cast<ProjectRecord?>().firstWhere(
|
||||
(project) => project?.workingDirectory == normalizedDirectory,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
final nextSelectedId = match?.id;
|
||||
if (_selectedProjectId == nextSelectedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedProjectId = nextSelectedId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeProject(String id) async {
|
||||
await _projectStore.update(
|
||||
(current) => current.where((project) => project.id != id).toList(),
|
||||
);
|
||||
_projects = List<ProjectRecord>.from(_projectStore.projects);
|
||||
if (_selectedProjectId == id) {
|
||||
_selectedProjectId = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _projectNameForDirectory(String workingDirectory) {
|
||||
final baseName = path.basename(workingDirectory);
|
||||
return baseName.isEmpty ? workingDirectory : baseName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:uuid/uuid.dart";
|
||||
|
||||
import "../../src/session/conversation_history.dart";
|
||||
import "../../src/session/session_store.dart";
|
||||
import "../../src/session/session_types.dart";
|
||||
|
||||
class SessionProvider extends ChangeNotifier {
|
||||
SessionProvider() {
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
final SessionStore _sessionStore = SessionStore.instance;
|
||||
final ConversationHistory _conversationHistory = ConversationHistory();
|
||||
|
||||
List<SessionSummary> _sessions = <SessionSummary>[];
|
||||
String? _currentSessionId;
|
||||
ConversationSession? _currentSession;
|
||||
String? _activeWorkingDirectory;
|
||||
|
||||
List<SessionSummary> get sessions => _sessions;
|
||||
String? get currentSessionId => _currentSessionId;
|
||||
ConversationSession? get currentSession => _currentSession;
|
||||
String? get activeWorkingDirectory => _activeWorkingDirectory;
|
||||
|
||||
List<SessionSummary> sessionsForWorkingDirectory(String? workingDirectory) {
|
||||
final normalizedDirectory = workingDirectory?.trim();
|
||||
if (normalizedDirectory == null || normalizedDirectory.isEmpty) {
|
||||
return List<SessionSummary>.unmodifiable(_sessions);
|
||||
}
|
||||
|
||||
return List<SessionSummary>.unmodifiable(
|
||||
_sessions.where(
|
||||
(session) => session.workingDirectory == normalizedDirectory,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectWorkingDirectory(String? workingDirectory) {
|
||||
_activeWorkingDirectory = workingDirectory?.trim();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearCurrentSession({String? workingDirectory}) {
|
||||
_conversationHistory.setSession(
|
||||
ConversationSession(
|
||||
id: "",
|
||||
name: "",
|
||||
created: DateTime.now().toUtc(),
|
||||
updated: DateTime.now().toUtc(),
|
||||
workingDirectory: workingDirectory?.trim(),
|
||||
),
|
||||
);
|
||||
_currentSession = null;
|
||||
_currentSessionId = null;
|
||||
_activeWorkingDirectory = workingDirectory?.trim();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
try {
|
||||
_sessions = await _sessionStore.listSessions();
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to load sessions", error, stackTrace);
|
||||
_sessions = <SessionSummary>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createNewSession({
|
||||
String? workingDirectory,
|
||||
String? name,
|
||||
}) async {
|
||||
try {
|
||||
const uuid = Uuid();
|
||||
final newSessionId = uuid.v4();
|
||||
final now = DateTime.now().toUtc();
|
||||
final normalizedDirectory = workingDirectory?.trim();
|
||||
|
||||
final newSession = ConversationSession(
|
||||
id: newSessionId,
|
||||
name: name ?? "New Chat",
|
||||
created: now,
|
||||
updated: now,
|
||||
workingDirectory:
|
||||
normalizedDirectory == null || normalizedDirectory.isEmpty
|
||||
? null
|
||||
: normalizedDirectory,
|
||||
);
|
||||
|
||||
await _sessionStore.saveSession(newSession);
|
||||
_conversationHistory.setSession(newSession);
|
||||
_currentSession = newSession;
|
||||
_currentSessionId = newSessionId;
|
||||
_activeWorkingDirectory = newSession.workingDirectory;
|
||||
|
||||
await _loadSessions();
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to create a new session", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadSession(String id) async {
|
||||
try {
|
||||
final session = await _sessionStore.loadSession(id);
|
||||
if (session != null) {
|
||||
_conversationHistory.setSession(session);
|
||||
_currentSession = session;
|
||||
_currentSessionId = id;
|
||||
_activeWorkingDirectory = session.workingDirectory;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to load session $id", error, stackTrace);
|
||||
_currentSession = null;
|
||||
_currentSessionId = null;
|
||||
_activeWorkingDirectory = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSession(String id) async {
|
||||
try {
|
||||
await _sessionStore.deleteSession(id);
|
||||
|
||||
if (_currentSessionId == id) {
|
||||
_conversationHistory.setSession(
|
||||
ConversationSession(
|
||||
id: "",
|
||||
name: "",
|
||||
created: DateTime.now().toUtc(),
|
||||
updated: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
_currentSession = null;
|
||||
_currentSessionId = null;
|
||||
_activeWorkingDirectory = null;
|
||||
}
|
||||
|
||||
await _loadSessions();
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to delete session $id", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshSessions() async {
|
||||
try {
|
||||
await _loadSessions();
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to refresh sessions", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
ConversationHistory getConversationHistory() => _conversationHistory;
|
||||
|
||||
void _logException(String message, Object error, StackTrace stackTrace) {
|
||||
print("$message: $error");
|
||||
print(stackTrace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "../../src/local_state.dart";
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
SettingsProvider(this._settingsStore) : settings = _settingsStore.settings;
|
||||
|
||||
static const Map<String, String> _legacyModelAliases = {
|
||||
"google/gemini-2.0-flash": "google/gemini-2.0-flash-001",
|
||||
};
|
||||
|
||||
final SettingsStore _settingsStore;
|
||||
LocalSettings settings;
|
||||
|
||||
String normalizeModelId(String? modelId) {
|
||||
if (modelId == null || modelId.isEmpty) {
|
||||
return "anthropic/claude-sonnet-4.6";
|
||||
}
|
||||
|
||||
return _legacyModelAliases[modelId] ?? modelId;
|
||||
}
|
||||
|
||||
Future<void> updateModel(String newModel) async {
|
||||
final normalizedModel = normalizeModelId(newModel);
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(model: normalizedModel),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateApiKey(String newKey) async {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(openRouterApiKey: newKey),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateTheme(String newTheme) async {
|
||||
await _settingsStore.update((current) => current.copyWith(theme: newTheme));
|
||||
settings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateEffortLevel(String newLevel) async {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(effortLevel: newLevel),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
await _settingsStore.update((_) => const LocalSettings());
|
||||
settings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "settings_provider.dart";
|
||||
|
||||
class ThemeProvider extends ChangeNotifier {
|
||||
ThemeProvider(this._settingsProvider) : _currentTheme = _settingsProvider.settings.theme {
|
||||
_settingsProvider.addListener(_onSettingsChanged);
|
||||
}
|
||||
|
||||
final SettingsProvider _settingsProvider;
|
||||
late String _currentTheme;
|
||||
|
||||
String get currentTheme => _currentTheme;
|
||||
bool get isDark => _currentTheme == "dark" || _currentTheme == "dark-ansi";
|
||||
|
||||
void _onSettingsChanged() {
|
||||
if (_currentTheme != _settingsProvider.settings.theme) {
|
||||
_currentTheme = _settingsProvider.settings.theme;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setTheme(String themeName) async {
|
||||
await _settingsProvider.updateTheme(themeName);
|
||||
_currentTheme = themeName;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settingsProvider.removeListener(_onSettingsChanged);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user