Add new features and update configurations for improved functionality
This commit is contained in:
+10
-1
@@ -6,6 +6,7 @@ import "src/project_store.dart";
|
||||
import "ui/app.dart";
|
||||
import "ui/providers/chat_provider.dart";
|
||||
import "ui/providers/cost_provider.dart";
|
||||
import "ui/providers/home_coordinator.dart";
|
||||
import "ui/providers/projects_provider.dart";
|
||||
import "ui/providers/session_provider.dart";
|
||||
import "ui/providers/settings_provider.dart";
|
||||
@@ -29,13 +30,21 @@ void main() async {
|
||||
create: (_) => CostProvider(),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => SessionProvider(),
|
||||
create: (_) => SessionProvider(projectStore),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => ChatProvider(
|
||||
context.read<SettingsProvider>(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => HomeCoordinator(
|
||||
context.read<ProjectsProvider>(),
|
||||
context.read<SessionProvider>(),
|
||||
context.read<ChatProvider>(),
|
||||
context.read<SettingsProvider>(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const ClawdApp(),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Agent execution context and shared state
|
||||
// Represents the environment in which agents operate
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import '../local_state.dart';
|
||||
|
||||
/// Shared context passed between agents
|
||||
class AgentContext {
|
||||
final String agentId;
|
||||
final String parentAgentId;
|
||||
final String sessionId;
|
||||
final String workingDirectory;
|
||||
final LocalSettings settings;
|
||||
|
||||
/// Conversation history visible to this agent
|
||||
final List<Map<String, dynamic>> conversationHistory;
|
||||
|
||||
/// Shared variables/state between agents
|
||||
final Map<String, dynamic> sharedState;
|
||||
|
||||
/// Files this agent has access to
|
||||
final Set<String> accessiblePaths;
|
||||
|
||||
/// Tools this agent can invoke
|
||||
final Set<String> allowedTools;
|
||||
|
||||
/// Results from sub-agents (if delegating work)
|
||||
final Map<String, AgentResult> subAgentResults;
|
||||
|
||||
DateTime createdAt;
|
||||
DateTime? completedAt;
|
||||
|
||||
AgentContext({
|
||||
required this.agentId,
|
||||
required this.parentAgentId,
|
||||
required this.sessionId,
|
||||
required this.workingDirectory,
|
||||
required this.settings,
|
||||
List<Map<String, dynamic>>? conversationHistory,
|
||||
Map<String, dynamic>? sharedState,
|
||||
Set<String>? accessiblePaths,
|
||||
Set<String>? allowedTools,
|
||||
}) : conversationHistory = conversationHistory ?? [],
|
||||
sharedState = sharedState ?? {},
|
||||
accessiblePaths = accessiblePaths ?? {},
|
||||
allowedTools = allowedTools ?? {},
|
||||
subAgentResults = {},
|
||||
createdAt = DateTime.now();
|
||||
|
||||
/// Create a child agent context
|
||||
AgentContext createChildContext({
|
||||
required String childAgentId,
|
||||
List<Map<String, dynamic>>? childConversationHistory,
|
||||
}) {
|
||||
return AgentContext(
|
||||
agentId: childAgentId,
|
||||
parentAgentId: agentId,
|
||||
sessionId: sessionId,
|
||||
workingDirectory: workingDirectory,
|
||||
settings: settings,
|
||||
conversationHistory: childConversationHistory ?? List.from(conversationHistory),
|
||||
sharedState: Map.from(sharedState),
|
||||
accessiblePaths: Set.from(accessiblePaths),
|
||||
allowedTools: Set.from(allowedTools),
|
||||
);
|
||||
}
|
||||
|
||||
/// Add shared state (visible to all agents)
|
||||
void setSharedVariable(String key, dynamic value) {
|
||||
sharedState[key] = value;
|
||||
}
|
||||
|
||||
/// Get shared variable
|
||||
dynamic getSharedVariable(String key) => sharedState[key];
|
||||
|
||||
/// Check if agent can access a path
|
||||
bool canAccessPath(String path) {
|
||||
if (accessiblePaths.isEmpty) {
|
||||
return true; // No restrictions
|
||||
}
|
||||
return accessiblePaths.contains(path) ||
|
||||
accessiblePaths.any((p) => path.startsWith(p));
|
||||
}
|
||||
|
||||
/// Check if agent can use a tool
|
||||
bool canUseTool(String toolName) {
|
||||
if (allowedTools.isEmpty) {
|
||||
return true; // No restrictions
|
||||
}
|
||||
return allowedTools.contains(toolName);
|
||||
}
|
||||
|
||||
/// Store sub-agent result
|
||||
void recordSubAgentResult(String subAgentId, AgentResult result) {
|
||||
subAgentResults[subAgentId] = result;
|
||||
}
|
||||
|
||||
/// Get sub-agent result
|
||||
AgentResult? getSubAgentResult(String subAgentId) => subAgentResults[subAgentId];
|
||||
|
||||
/// Mark agent as complete
|
||||
void markComplete() {
|
||||
completedAt = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get agent duration
|
||||
Duration? getDuration() {
|
||||
if (completedAt == null) {
|
||||
return null;
|
||||
}
|
||||
return completedAt!.difference(createdAt);
|
||||
}
|
||||
|
||||
/// Export context as JSON (for debugging/logging)
|
||||
Map<String, dynamic> toJson() => {
|
||||
'agent_id': agentId,
|
||||
'parent_agent_id': parentAgentId,
|
||||
'session_id': sessionId,
|
||||
'working_directory': workingDirectory,
|
||||
'conversation_history_length': conversationHistory.length,
|
||||
'shared_state_keys': sharedState.keys.toList(),
|
||||
'accessible_paths': accessiblePaths.toList(),
|
||||
'allowed_tools': allowedTools.toList(),
|
||||
'sub_agent_results': subAgentResults.length,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'completed_at': completedAt?.toIso8601String(),
|
||||
'duration_ms': getDuration()?.inMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// Result of an agent execution
|
||||
class AgentResult {
|
||||
final String agentId;
|
||||
final bool success;
|
||||
final String output;
|
||||
final String? error;
|
||||
final Map<String, dynamic>? data;
|
||||
final Duration? duration;
|
||||
|
||||
const AgentResult({
|
||||
required this.agentId,
|
||||
required this.success,
|
||||
required this.output,
|
||||
this.error,
|
||||
this.data,
|
||||
this.duration,
|
||||
});
|
||||
|
||||
factory AgentResult.success({
|
||||
required String agentId,
|
||||
required String output,
|
||||
Map<String, dynamic>? data,
|
||||
Duration? duration,
|
||||
}) =>
|
||||
AgentResult(
|
||||
agentId: agentId,
|
||||
success: true,
|
||||
output: output,
|
||||
error: null,
|
||||
data: data,
|
||||
duration: duration,
|
||||
);
|
||||
|
||||
factory AgentResult.failure({
|
||||
required String agentId,
|
||||
required String error,
|
||||
Duration? duration,
|
||||
}) =>
|
||||
AgentResult(
|
||||
agentId: agentId,
|
||||
success: false,
|
||||
output: '',
|
||||
error: error,
|
||||
data: null,
|
||||
duration: duration,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'agent_id': agentId,
|
||||
'success': success,
|
||||
'output': output,
|
||||
'error': error,
|
||||
'data': data,
|
||||
'duration_ms': duration?.inMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// Agent definition (what kind of agent to spawn)
|
||||
class AgentDefinition {
|
||||
final String type; // e.g., 'researcher', 'coder', 'reviewer', 'planner'
|
||||
final String? model; // Override model for this agent
|
||||
final String? systemPrompt; // Custom system prompt
|
||||
final Map<String, dynamic>? config; // Agent-specific config
|
||||
|
||||
const AgentDefinition({
|
||||
required this.type,
|
||||
this.model,
|
||||
this.systemPrompt,
|
||||
this.config,
|
||||
});
|
||||
|
||||
/// Get system prompt for this agent type
|
||||
String getSystemPrompt() {
|
||||
if (systemPrompt != null) {
|
||||
return systemPrompt!;
|
||||
}
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case 'researcher':
|
||||
return '''You are a research agent. Your job is to:
|
||||
1. Search for information using available tools
|
||||
2. Aggregate findings from multiple sources
|
||||
3. Provide a comprehensive summary of findings
|
||||
Focus on accuracy and citing sources.''';
|
||||
|
||||
case 'coder':
|
||||
return '''You are a code generation and implementation agent. Your job is to:
|
||||
1. Write or modify code to solve problems
|
||||
2. Test the code to ensure it works
|
||||
3. Document the implementation
|
||||
Focus on correctness and best practices.''';
|
||||
|
||||
case 'reviewer':
|
||||
return '''You are a code review and analysis agent. Your job is to:
|
||||
1. Review code for correctness and quality
|
||||
2. Identify potential issues and improvements
|
||||
3. Provide detailed feedback
|
||||
Focus on constructive and actionable feedback.''';
|
||||
|
||||
case 'planner':
|
||||
return '''You are a planning and strategy agent. Your job is to:
|
||||
1. Break down complex tasks into steps
|
||||
2. Identify dependencies and risks
|
||||
3. Create detailed implementation plans
|
||||
Focus on thoroughness and clarity.''';
|
||||
|
||||
case 'executor':
|
||||
return '''You are an execution agent. Your job is to:
|
||||
1. Execute planned steps
|
||||
2. Handle errors and recover gracefully
|
||||
3. Report progress and results
|
||||
Focus on reliability and completeness.''';
|
||||
|
||||
default:
|
||||
return '''You are an AI agent. Use available tools to accomplish your assigned task.
|
||||
Focus on being thorough, accurate, and helpful.''';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Agent coordination engine
|
||||
// Manages multi-agent workflows and communication
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../local_state.dart';
|
||||
import 'agent_context.dart';
|
||||
import 'agent_executor.dart';
|
||||
|
||||
/// Orchestrates multiple agents working together
|
||||
class AgentCoordinator {
|
||||
static final AgentCoordinator _instance = AgentCoordinator._internal();
|
||||
factory AgentCoordinator() => _instance;
|
||||
AgentCoordinator._internal() : _executor = AgentExecutor();
|
||||
|
||||
final AgentExecutor _executor;
|
||||
final Map<String, AgentWorkflow> _workflows = {};
|
||||
int _workflowCounter = 1;
|
||||
|
||||
/// Create a new workflow
|
||||
AgentWorkflow createWorkflow({
|
||||
required String name,
|
||||
required String sessionId,
|
||||
required String workingDirectory,
|
||||
required LocalSettings settings,
|
||||
}) {
|
||||
final workflow = AgentWorkflow(
|
||||
id: 'workflow_${_workflowCounter++}',
|
||||
name: name,
|
||||
sessionId: sessionId,
|
||||
workingDirectory: workingDirectory,
|
||||
settings: settings,
|
||||
coordinator: this,
|
||||
executor: _executor,
|
||||
);
|
||||
|
||||
_workflows[workflow.id] = workflow;
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/// Get a workflow
|
||||
AgentWorkflow? getWorkflow(String workflowId) => _workflows[workflowId];
|
||||
|
||||
/// List all workflows
|
||||
List<AgentWorkflow> listWorkflows() => _workflows.values.toList();
|
||||
}
|
||||
|
||||
/// Represents a workflow (sequence of agents)
|
||||
class AgentWorkflow {
|
||||
final String id;
|
||||
final String name;
|
||||
final String sessionId;
|
||||
final String workingDirectory;
|
||||
final LocalSettings settings;
|
||||
final AgentCoordinator coordinator;
|
||||
final AgentExecutor executor;
|
||||
|
||||
DateTime createdAt = DateTime.now();
|
||||
DateTime? completedAt;
|
||||
|
||||
final List<WorkflowStep> steps = [];
|
||||
final Map<String, dynamic> sharedState = {};
|
||||
final Map<String, AgentResult> agentResults = {};
|
||||
|
||||
int currentStepIndex = 0;
|
||||
bool _isRunning = false;
|
||||
bool _cancelled = false;
|
||||
|
||||
AgentWorkflow({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.sessionId,
|
||||
required this.workingDirectory,
|
||||
required this.settings,
|
||||
required this.coordinator,
|
||||
required this.executor,
|
||||
});
|
||||
|
||||
/// Add a step to the workflow
|
||||
void addStep(
|
||||
AgentDefinition agentDef,
|
||||
String task, {
|
||||
bool dependsOnPrevious = true,
|
||||
}) {
|
||||
steps.add(WorkflowStep(
|
||||
index: steps.length,
|
||||
definition: agentDef,
|
||||
task: task,
|
||||
dependsOnPrevious: dependsOnPrevious,
|
||||
));
|
||||
}
|
||||
|
||||
/// Execute the workflow
|
||||
Future<WorkflowResult> execute({
|
||||
required String apiKey,
|
||||
required String model,
|
||||
}) async {
|
||||
if (_isRunning) {
|
||||
throw Exception('Workflow $id is already running');
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
final startTime = DateTime.now();
|
||||
|
||||
try {
|
||||
for (int i = 0; i < steps.length; i++) {
|
||||
if (_cancelled) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentStepIndex = i;
|
||||
final step = steps[i];
|
||||
|
||||
// Prepare task (substitute variables from previous results)
|
||||
var task = step.task;
|
||||
for (final result in agentResults.values) {
|
||||
task = task.replaceAll(
|
||||
'\${result}',
|
||||
result.output,
|
||||
);
|
||||
}
|
||||
|
||||
// Create context for this agent
|
||||
final context = AgentContext(
|
||||
agentId: 'workflow_${id}_agent_$i',
|
||||
parentAgentId: id,
|
||||
sessionId: sessionId,
|
||||
workingDirectory: workingDirectory,
|
||||
settings: settings,
|
||||
sharedState: Map.from(sharedState),
|
||||
);
|
||||
|
||||
// Spawn agent
|
||||
final agentId = await executor.spawnAgent(
|
||||
definition: step.definition,
|
||||
context: context,
|
||||
task: task,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
);
|
||||
|
||||
// Wait for agent to complete
|
||||
final result = await executor.waitForAgent(agentId);
|
||||
agentResults[agentId] = result;
|
||||
|
||||
// Update shared state from agent result
|
||||
if (result.data != null) {
|
||||
sharedState.addAll(result.data!);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
// Stop workflow on agent failure
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
completedAt = DateTime.now();
|
||||
|
||||
return WorkflowResult(
|
||||
id: id,
|
||||
name: name,
|
||||
success: agentResults.values.every((r) => r.success),
|
||||
agentResults: agentResults,
|
||||
sharedState: sharedState,
|
||||
duration: completedAt!.difference(startTime),
|
||||
);
|
||||
} catch (e) {
|
||||
completedAt = DateTime.now();
|
||||
rethrow;
|
||||
} finally {
|
||||
_isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the workflow
|
||||
void cancel() {
|
||||
_cancelled = true;
|
||||
}
|
||||
|
||||
/// Get workflow status
|
||||
Map<String, dynamic> getStatus() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'is_running': _isRunning,
|
||||
'current_step': currentStepIndex,
|
||||
'total_steps': steps.length,
|
||||
'completed_agents': agentResults.length,
|
||||
'completed_at': completedAt?.toIso8601String(),
|
||||
'duration_ms': completedAt?.difference(createdAt).inMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// A single step in a workflow
|
||||
class WorkflowStep {
|
||||
final int index;
|
||||
final AgentDefinition definition;
|
||||
final String task;
|
||||
final bool dependsOnPrevious;
|
||||
|
||||
WorkflowStep({
|
||||
required this.index,
|
||||
required this.definition,
|
||||
required this.task,
|
||||
required this.dependsOnPrevious,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of a complete workflow
|
||||
class WorkflowResult {
|
||||
final String id;
|
||||
final String name;
|
||||
final bool success;
|
||||
final Map<String, AgentResult> agentResults;
|
||||
final Map<String, dynamic> sharedState;
|
||||
final Duration duration;
|
||||
|
||||
const WorkflowResult({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.success,
|
||||
required this.agentResults,
|
||||
required this.sharedState,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
/// Get combined output from all agents
|
||||
String getCombinedOutput() {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (final entry in agentResults.entries) {
|
||||
buffer.writeln('Agent: ${entry.key}');
|
||||
buffer.writeln(entry.value.output);
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Export as JSON
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'success': success,
|
||||
'agent_results': {
|
||||
for (final e in agentResults.entries) e.key: e.value.toJson(),
|
||||
},
|
||||
'shared_state': sharedState,
|
||||
'duration_ms': duration.inMilliseconds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Agent execution engine
|
||||
// Spawns and manages agent instances
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../api/openrouter_client.dart';
|
||||
import '../chat/tool_loop_service.dart';
|
||||
import '../local_state.dart';
|
||||
import 'agent_context.dart';
|
||||
|
||||
/// Executes a single agent
|
||||
class AgentExecutor {
|
||||
static final AgentExecutor _instance = AgentExecutor._internal();
|
||||
factory AgentExecutor() => _instance;
|
||||
AgentExecutor._internal();
|
||||
|
||||
final Map<String, _RunningAgent> _agents = {};
|
||||
int _idCounter = 1;
|
||||
|
||||
/// Spawn and run an agent
|
||||
Future<String> spawnAgent({
|
||||
required AgentDefinition definition,
|
||||
required AgentContext context,
|
||||
required String task,
|
||||
required String apiKey,
|
||||
required String model,
|
||||
}) async {
|
||||
final agentId = 'agent_${_idCounter++}';
|
||||
|
||||
final agent = _RunningAgent(
|
||||
id: agentId,
|
||||
definition: definition,
|
||||
context: context.createChildContext(childAgentId: agentId),
|
||||
task: task,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
);
|
||||
|
||||
_agents[agentId] = agent;
|
||||
|
||||
// Start agent in background
|
||||
agent.run().then((result) {
|
||||
// Agent completed
|
||||
context.recordSubAgentResult(agentId, result);
|
||||
}).catchError((e) {
|
||||
print('Agent $agentId failed: $e');
|
||||
context.recordSubAgentResult(
|
||||
agentId,
|
||||
AgentResult.failure(agentId: agentId, error: e.toString()),
|
||||
);
|
||||
});
|
||||
|
||||
return agentId;
|
||||
}
|
||||
|
||||
/// Get agent result (non-blocking)
|
||||
AgentResult? getResult(String agentId) {
|
||||
final agent = _agents[agentId];
|
||||
if (agent == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return agent.result;
|
||||
}
|
||||
|
||||
/// Wait for agent completion
|
||||
Future<AgentResult> waitForAgent(String agentId,
|
||||
{Duration timeout = const Duration(hours: 24)}) async {
|
||||
final agent = _agents[agentId];
|
||||
if (agent == null) {
|
||||
throw Exception('Agent $agentId not found');
|
||||
}
|
||||
|
||||
return agent.waitForCompletion(timeout: timeout);
|
||||
}
|
||||
|
||||
/// Get all running agents
|
||||
List<Map<String, dynamic>> getAllAgents() {
|
||||
return _agents.entries.map((e) {
|
||||
return {
|
||||
'id': e.key,
|
||||
'type': e.value.definition.type,
|
||||
'task': e.value.task,
|
||||
'status': e.value.isComplete ? 'completed' : 'running',
|
||||
'result': e.value.result?.toJson(),
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Cancel an agent
|
||||
Future<bool> cancelAgent(String agentId) async {
|
||||
final agent = _agents[agentId];
|
||||
if (agent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
agent.cancel();
|
||||
_agents.remove(agentId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a running agent instance
|
||||
class _RunningAgent {
|
||||
final String id;
|
||||
final AgentDefinition definition;
|
||||
final AgentContext context;
|
||||
final String task;
|
||||
final String apiKey;
|
||||
final String model;
|
||||
|
||||
DateTime _startTime = DateTime.now();
|
||||
DateTime? _endTime;
|
||||
AgentResult? result;
|
||||
bool _cancelled = false;
|
||||
|
||||
final Completer<AgentResult> _completer = Completer();
|
||||
|
||||
_RunningAgent({
|
||||
required this.id,
|
||||
required this.definition,
|
||||
required this.context,
|
||||
required this.task,
|
||||
required this.apiKey,
|
||||
required this.model,
|
||||
});
|
||||
|
||||
bool get isComplete => _endTime != null;
|
||||
|
||||
/// Run the agent
|
||||
Future<AgentResult> run() async {
|
||||
try {
|
||||
// Create API client for this agent
|
||||
final client = OpenRouterClient(
|
||||
config: OpenRouterConfig(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
),
|
||||
);
|
||||
|
||||
// Create tool loop for this agent
|
||||
final toolLoop = ToolLoopService();
|
||||
|
||||
// Build agent-specific system prompt
|
||||
final systemPrompt = '''${definition.getSystemPrompt()}
|
||||
|
||||
Task: $task
|
||||
|
||||
Context:
|
||||
- Session ID: ${context.sessionId}
|
||||
- Working Directory: ${context.workingDirectory}
|
||||
- Parent Agent: ${context.parentAgentId}
|
||||
${context.sharedState.isNotEmpty ? '- Shared State: ${context.sharedState}' : ''}
|
||||
|
||||
Use available tools to complete this task. Report your findings clearly.''';
|
||||
|
||||
// Run the tool loop for this agent
|
||||
final toolResult = await toolLoop.runTurn(
|
||||
client: client,
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
getSettings: () => context.settings,
|
||||
apiMessages: context.conversationHistory,
|
||||
userText: task,
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
|
||||
// Extract output
|
||||
final output = toolResult.responseText;
|
||||
|
||||
// Create result
|
||||
result = AgentResult.success(
|
||||
agentId: id,
|
||||
output: output,
|
||||
data: {
|
||||
'messages_exchanged': toolResult.apiMessages.length,
|
||||
'web_searches': toolResult.webSearchRequests,
|
||||
'web_fetches': toolResult.webFetchRequests,
|
||||
},
|
||||
duration: DateTime.now().difference(_startTime),
|
||||
);
|
||||
|
||||
_endTime = DateTime.now();
|
||||
_completer.complete(result!);
|
||||
|
||||
return result!;
|
||||
} catch (e, st) {
|
||||
result = AgentResult.failure(
|
||||
agentId: id,
|
||||
error: e.toString(),
|
||||
duration: DateTime.now().difference(_startTime),
|
||||
);
|
||||
|
||||
_endTime = DateTime.now();
|
||||
_completer.completeError(e, st);
|
||||
|
||||
return result!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for agent to complete
|
||||
Future<AgentResult> waitForCompletion({Duration timeout = const Duration(hours: 24)}) {
|
||||
return _completer.future.timeout(timeout);
|
||||
}
|
||||
|
||||
/// Cancel this agent
|
||||
void cancel() {
|
||||
_cancelled = true;
|
||||
}
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
// Anthropic API client
|
||||
// Ported from old_repo/services/api/client.ts
|
||||
|
||||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "../services/oauth_service.dart";
|
||||
import "api_types.dart";
|
||||
import "request_builder.dart";
|
||||
import "response_parser.dart";
|
||||
|
||||
// Configuration for the Anthropic API client
|
||||
class AnthropicClientConfig {
|
||||
final String apiKey;
|
||||
final String baseUrl;
|
||||
final int maxRetries;
|
||||
final String? model;
|
||||
final String? source;
|
||||
final bool enableLogging;
|
||||
|
||||
const AnthropicClientConfig({
|
||||
required this.apiKey,
|
||||
required this.baseUrl,
|
||||
this.maxRetries = 2,
|
||||
this.model,
|
||||
this.source,
|
||||
this.enableLogging = false,
|
||||
});
|
||||
}
|
||||
|
||||
// Main Anthropic API client
|
||||
class AnthropicClient {
|
||||
final AnthropicClientConfig _config;
|
||||
late HttpClient _httpClient;
|
||||
|
||||
AnthropicClient({required AnthropicClientConfig config}) : _config = config {
|
||||
_httpClient = HttpClient();
|
||||
_httpClient.connectionTimeout = Duration(seconds: 600);
|
||||
}
|
||||
|
||||
// Get API key from environment or config
|
||||
String _getApiKey() {
|
||||
if (_config.apiKey.isNotEmpty) {
|
||||
return _config.apiKey;
|
||||
}
|
||||
|
||||
final env = Platform.environment;
|
||||
return env["ANTHROPIC_API_KEY"] ??
|
||||
env["CLAUDE_API_KEY"] ??
|
||||
env["CLAUDE_CODE_API_KEY"] ??
|
||||
"";
|
||||
}
|
||||
|
||||
// Get base URL from environment or config
|
||||
String _getBaseUrl() {
|
||||
if (_config.baseUrl.isNotEmpty) {
|
||||
return _config.baseUrl;
|
||||
}
|
||||
|
||||
final env = Platform.environment;
|
||||
final override =
|
||||
env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
|
||||
if (override != null && override.isNotEmpty) {
|
||||
return override;
|
||||
}
|
||||
|
||||
return "https://api.anthropic.com";
|
||||
}
|
||||
|
||||
// Build headers for API request
|
||||
Map<String, String> _buildHeaders() {
|
||||
final builder = HeaderBuilder();
|
||||
|
||||
// Add API key authentication
|
||||
final apiKey = _getApiKey();
|
||||
if (apiKey.isNotEmpty) {
|
||||
builder.addAuthHeader(apiKey);
|
||||
}
|
||||
|
||||
// Add custom headers from environment
|
||||
builder.addCustomHeadersFromEnv();
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
// Send a message to Claude
|
||||
Future<ApiMessage> createMessage({
|
||||
required String model,
|
||||
required int maxTokens,
|
||||
required List<Map<String, dynamic>> messages,
|
||||
String? system,
|
||||
double? temperature,
|
||||
List<Map<String, dynamic>>? tools,
|
||||
String? toolChoice,
|
||||
}) async {
|
||||
final requestBuilder = MessageRequestBuilder(
|
||||
model: model,
|
||||
maxTokens: maxTokens,
|
||||
messages: messages,
|
||||
);
|
||||
|
||||
if (system != null) {
|
||||
requestBuilder.withSystem(system);
|
||||
}
|
||||
|
||||
if (temperature != null) {
|
||||
requestBuilder.withTemperature(temperature);
|
||||
}
|
||||
|
||||
if (tools != null && tools.isNotEmpty) {
|
||||
requestBuilder.withTools(tools);
|
||||
if (toolChoice != null) {
|
||||
requestBuilder.withToolChoice(toolChoice);
|
||||
}
|
||||
}
|
||||
|
||||
final request = requestBuilder.build();
|
||||
|
||||
return _makeRequest(
|
||||
method: "POST",
|
||||
endpoint: "/v1/messages",
|
||||
body: request.toJson(),
|
||||
).then((response) {
|
||||
return ResponseParser.parseMessageResponse(response);
|
||||
});
|
||||
}
|
||||
|
||||
// List available models (API endpoint)
|
||||
Future<List<String>> listModels() async {
|
||||
final response = await _makeRequest(
|
||||
method: "GET",
|
||||
endpoint: "/v1/models",
|
||||
);
|
||||
|
||||
// parse models from response
|
||||
final models = <String>[];
|
||||
if (response["data"] is List) {
|
||||
for (final model in response["data"] as List) {
|
||||
if (model is Map<String, dynamic> && model["id"] is String) {
|
||||
models.add(model["id"] as String);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
// Get a single model's details
|
||||
Future<Map<String, dynamic>> getModel(String modelId) async {
|
||||
return _makeRequest(
|
||||
method: "GET",
|
||||
endpoint: "/v1/models/$modelId",
|
||||
);
|
||||
}
|
||||
|
||||
// Count tokens for a message (beta API)
|
||||
Future<int> countTokens({
|
||||
required String model,
|
||||
required List<Map<String, dynamic>> messages,
|
||||
String? system,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
};
|
||||
|
||||
if (system != null) {
|
||||
body["system"] = system;
|
||||
}
|
||||
|
||||
final response = await _makeRequest(
|
||||
method: "POST",
|
||||
endpoint: "/v1/messages/count_tokens",
|
||||
body: body,
|
||||
);
|
||||
|
||||
final count = response["input_tokens"];
|
||||
return count is int ? count : 0;
|
||||
}
|
||||
|
||||
// Internal: make HTTP request to API
|
||||
Future<Map<String, dynamic>> _makeRequest({
|
||||
required String method,
|
||||
required String endpoint,
|
||||
Map<String, dynamic>? body,
|
||||
}) async {
|
||||
final baseUrl = _getBaseUrl();
|
||||
final url = Uri.parse("$baseUrl$endpoint");
|
||||
final headers = _buildHeaders();
|
||||
|
||||
if (_config.enableLogging) {
|
||||
_log("[API REQUEST] $method $endpoint");
|
||||
}
|
||||
|
||||
try {
|
||||
final request = await _httpClient.openUrl(method, url);
|
||||
|
||||
// Set headers
|
||||
headers.forEach((key, value) {
|
||||
request.headers.set(key, value);
|
||||
});
|
||||
|
||||
// Add content type for JSON
|
||||
request.headers.contentType = ContentType.json;
|
||||
|
||||
// Write body if present
|
||||
if (body != null) {
|
||||
request.write(jsonEncode(body));
|
||||
}
|
||||
|
||||
final response = await request.close();
|
||||
final responseBody = await response.transform(utf8.decoder).join();
|
||||
|
||||
if (_config.enableLogging) {
|
||||
_log("[API RESPONSE] ${response.statusCode}");
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if (response.statusCode >= 400) {
|
||||
_handleErrorResponse(response.statusCode, responseBody);
|
||||
}
|
||||
|
||||
// Parse response
|
||||
final decoded = jsonDecode(responseBody);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw Exception("Invalid API response format");
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} catch (e) {
|
||||
if (_config.enableLogging) {
|
||||
_log("[API ERROR] $e");
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
void _handleErrorResponse(int statusCode, String body) {
|
||||
late String errorMessage;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final error = ErrorParser.extractErrorMessage(decoded);
|
||||
if (error != null) {
|
||||
errorMessage = error;
|
||||
} else {
|
||||
errorMessage = "HTTP $statusCode";
|
||||
}
|
||||
} else {
|
||||
errorMessage = "HTTP $statusCode";
|
||||
}
|
||||
} catch (_) {
|
||||
errorMessage = "HTTP $statusCode";
|
||||
}
|
||||
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
throw AuthenticationException(errorMessage);
|
||||
} else if (statusCode == 429) {
|
||||
throw RateLimitException(errorMessage);
|
||||
} else if (statusCode == 413) {
|
||||
throw RequestTooLargeException(errorMessage);
|
||||
} else {
|
||||
throw ApiException(errorMessage, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal logging
|
||||
void _log(String message) {
|
||||
// could wire this to real logging later
|
||||
print("[AnthropicClient] $message");
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
void close() {
|
||||
_httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Exception classes for API errors
|
||||
class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
ApiException(this.message, [this.statusCode]);
|
||||
|
||||
@override
|
||||
String toString() => "ApiException: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}";
|
||||
}
|
||||
|
||||
class AuthenticationException extends ApiException {
|
||||
AuthenticationException(String message) : super(message, 401);
|
||||
|
||||
@override
|
||||
String toString() => "AuthenticationException: $message";
|
||||
}
|
||||
|
||||
class RateLimitException extends ApiException {
|
||||
RateLimitException(String message) : super(message, 429);
|
||||
|
||||
@override
|
||||
String toString() => "RateLimitException: $message";
|
||||
}
|
||||
|
||||
class RequestTooLargeException extends ApiException {
|
||||
RequestTooLargeException(String message) : super(message, 413);
|
||||
|
||||
@override
|
||||
String toString() => "RequestTooLargeException: $message";
|
||||
}
|
||||
|
||||
// Factory to create client from environment
|
||||
class AnthropicClientFactory {
|
||||
static Future<AnthropicClient> create({
|
||||
String? apiKey,
|
||||
String? baseUrl,
|
||||
int maxRetries = 2,
|
||||
String? model,
|
||||
String? source,
|
||||
bool enableLogging = false,
|
||||
}) async {
|
||||
// Try to get OAuth tokens if available
|
||||
final tokens = await loadStoredTokens();
|
||||
final resolvedApiKey = apiKey ?? _resolveApiKey();
|
||||
|
||||
if (resolvedApiKey.isEmpty && tokens == null) {
|
||||
throw Exception("No API key found and no OAuth tokens available");
|
||||
}
|
||||
|
||||
final config = AnthropicClientConfig(
|
||||
apiKey: resolvedApiKey,
|
||||
baseUrl: baseUrl ?? _resolveBaseUrl(),
|
||||
maxRetries: maxRetries,
|
||||
model: model,
|
||||
source: source,
|
||||
enableLogging: enableLogging,
|
||||
);
|
||||
|
||||
return AnthropicClient(config: config);
|
||||
}
|
||||
|
||||
static String _resolveApiKey() {
|
||||
final env = Platform.environment;
|
||||
return env["ANTHROPIC_API_KEY"] ??
|
||||
env["CLAUDE_API_KEY"] ??
|
||||
env["CLAUDE_CODE_API_KEY"] ??
|
||||
"";
|
||||
}
|
||||
|
||||
static String _resolveBaseUrl() {
|
||||
final env = Platform.environment;
|
||||
final override =
|
||||
env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
|
||||
if (override != null && override.isNotEmpty) {
|
||||
return override;
|
||||
}
|
||||
return "https://api.anthropic.com";
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,10 @@ class ApiMessage {
|
||||
final Map<String, dynamic>? usage;
|
||||
final int? inputTokens;
|
||||
final int? outputTokens;
|
||||
final int? cacheCreationInputTokens;
|
||||
final int? cacheReadInputTokens;
|
||||
final int? webSearchRequests;
|
||||
final int? webFetchRequests;
|
||||
|
||||
const ApiMessage({
|
||||
required this.id,
|
||||
@@ -118,8 +122,19 @@ class ApiMessage {
|
||||
this.usage,
|
||||
this.inputTokens,
|
||||
this.outputTokens,
|
||||
this.cacheCreationInputTokens,
|
||||
this.cacheReadInputTokens,
|
||||
this.webSearchRequests,
|
||||
this.webFetchRequests,
|
||||
});
|
||||
|
||||
/// Total context window size — matches Claude Code's getTokenCountFromUsage()
|
||||
int get contextTokens =>
|
||||
(inputTokens ?? 0) +
|
||||
(cacheCreationInputTokens ?? 0) +
|
||||
(cacheReadInputTokens ?? 0) +
|
||||
(outputTokens ?? 0);
|
||||
|
||||
factory ApiMessage.fromJson(Map<String, dynamic> json) {
|
||||
int? extractInputTokens() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
@@ -133,6 +148,38 @@ class ApiMessage {
|
||||
(usage?["completion_tokens"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
int? extractWebSearchRequests() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
final direct = (usage?["web_search_requests"] as num?)?.toInt();
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
final serverToolUse = usage?["server_tool_use"];
|
||||
if (serverToolUse is Map<String, dynamic>) {
|
||||
return (serverToolUse["web_search_requests"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
int? extractWebFetchRequests() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
final direct = (usage?["web_fetch_requests"] as num?)?.toInt();
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
final serverToolUse = usage?["server_tool_use"];
|
||||
if (serverToolUse is Map<String, dynamic>) {
|
||||
return (serverToolUse["web_fetch_requests"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
final rawUsage = json["usage"] as Map<String, dynamic>?;
|
||||
|
||||
return ApiMessage(
|
||||
id: json["id"] as String,
|
||||
type: json["type"] as String? ?? "message",
|
||||
@@ -141,9 +188,13 @@ class ApiMessage {
|
||||
model: json["model"] as String,
|
||||
stopReason:
|
||||
json["stop_reason"] as String? ?? json["finish_reason"] as String?,
|
||||
usage: json["usage"] as Map<String, dynamic>?,
|
||||
usage: rawUsage,
|
||||
inputTokens: extractInputTokens(),
|
||||
outputTokens: extractOutputTokens(),
|
||||
cacheCreationInputTokens: (rawUsage?["cache_creation_input_tokens"] as num?)?.toInt(),
|
||||
cacheReadInputTokens: (rawUsage?["cache_read_input_tokens"] as num?)?.toInt(),
|
||||
webSearchRequests: extractWebSearchRequests(),
|
||||
webFetchRequests: extractWebFetchRequests(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,6 +258,36 @@ class ApiMessage {
|
||||
return (usage?["completion_tokens"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
int? extractWebSearchRequests() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
final direct = (usage?["web_search_requests"] as num?)?.toInt();
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
final serverToolUse = usage?["server_tool_use"];
|
||||
if (serverToolUse is Map<String, dynamic>) {
|
||||
return (serverToolUse["web_search_requests"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
int? extractWebFetchRequests() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
final direct = (usage?["web_fetch_requests"] as num?)?.toInt();
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
final serverToolUse = usage?["server_tool_use"];
|
||||
if (serverToolUse is Map<String, dynamic>) {
|
||||
return (serverToolUse["web_fetch_requests"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return ApiMessage(
|
||||
id: json["id"] as String? ?? "",
|
||||
type: "message",
|
||||
@@ -219,6 +300,8 @@ class ApiMessage {
|
||||
usage: json["usage"] as Map<String, dynamic>?,
|
||||
inputTokens: extractInputTokens(),
|
||||
outputTokens: extractOutputTokens(),
|
||||
webSearchRequests: extractWebSearchRequests(),
|
||||
webFetchRequests: extractWebFetchRequests(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+294
-167
@@ -4,6 +4,7 @@
|
||||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
import "dart:math";
|
||||
|
||||
import "api_types.dart";
|
||||
import "request_builder.dart";
|
||||
@@ -12,12 +13,14 @@ import "response_parser.dart";
|
||||
class OpenRouterConfig {
|
||||
final String apiKey;
|
||||
final int maxRetries;
|
||||
final Duration requestTimeout;
|
||||
final String? model;
|
||||
final bool enableLogging;
|
||||
|
||||
const OpenRouterConfig({
|
||||
required this.apiKey,
|
||||
this.maxRetries = 2,
|
||||
this.maxRetries = 10,
|
||||
this.requestTimeout = const Duration(seconds: 300),
|
||||
this.model,
|
||||
this.enableLogging = false,
|
||||
});
|
||||
@@ -29,6 +32,8 @@ class OpenRouterClient {
|
||||
bool _requestCancelled = false;
|
||||
|
||||
static const String _baseUrl = "https://openrouter.ai/api/v1";
|
||||
static const int _baseRetryDelayMs = 500;
|
||||
static const int _maxRetryDelayMs = 32000;
|
||||
|
||||
OpenRouterClient({required OpenRouterConfig config}) : _config = config {
|
||||
_httpClient = HttpClient();
|
||||
@@ -94,10 +99,12 @@ class OpenRouterClient {
|
||||
}
|
||||
}
|
||||
|
||||
final response = await _makeRequest(
|
||||
method: "POST",
|
||||
endpoint: "/chat/completions",
|
||||
body: requestBody,
|
||||
final response = await _withRetry(
|
||||
() => _makeRequest(
|
||||
method: "POST",
|
||||
endpoint: "/chat/completions",
|
||||
body: requestBody,
|
||||
),
|
||||
);
|
||||
|
||||
return ResponseParser.parseOpenRouterResponse(response);
|
||||
@@ -144,175 +151,182 @@ class OpenRouterClient {
|
||||
final url = Uri.parse("$_baseUrl/chat/completions");
|
||||
final headers = _buildHeaders();
|
||||
|
||||
final textBuffer = StringBuffer();
|
||||
final toolCalls = <int, _StreamingToolCallBuilder>{};
|
||||
String responseId = "";
|
||||
String responseModel = model;
|
||||
String? finishReason;
|
||||
Map<String, dynamic>? usage;
|
||||
bool hasVisibleOutput = false;
|
||||
return _withRetry(() async {
|
||||
final textBuffer = StringBuffer();
|
||||
final toolCalls = <int, _StreamingToolCallBuilder>{};
|
||||
String responseId = "";
|
||||
String responseModel = model;
|
||||
String? finishReason;
|
||||
Map<String, dynamic>? usage;
|
||||
|
||||
try {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
|
||||
final request = await _httpClient.openUrl("POST", url);
|
||||
headers.forEach((key, value) {
|
||||
request.headers.set(key, value);
|
||||
});
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.write(jsonEncode(requestBody));
|
||||
|
||||
final response = await request.close();
|
||||
if (response.statusCode >= 400) {
|
||||
final responseBody = await response.transform(utf8.decoder).join();
|
||||
print(
|
||||
"OpenRouter API error ${response.statusCode} for /chat/completions: $responseBody",
|
||||
);
|
||||
_handleErrorResponse(response.statusCode, responseBody);
|
||||
}
|
||||
|
||||
final responseStream = response
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
|
||||
await for (final line in responseStream) {
|
||||
try {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
|
||||
final event = StreamingResponseParser.parseStreamLine(line);
|
||||
if (StreamingResponseParser.isDone(event)) {
|
||||
break;
|
||||
}
|
||||
if (event == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = event["id"];
|
||||
if (id is String && id.isNotEmpty) {
|
||||
responseId = id;
|
||||
}
|
||||
|
||||
final streamedModel = event["model"];
|
||||
if (streamedModel is String && streamedModel.isNotEmpty) {
|
||||
responseModel = streamedModel;
|
||||
}
|
||||
|
||||
final rawUsage = event["usage"];
|
||||
if (rawUsage is Map<String, dynamic>) {
|
||||
usage = rawUsage;
|
||||
}
|
||||
|
||||
final choices = event["choices"];
|
||||
if (choices is! List || choices.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final firstChoice = choices.first;
|
||||
if (firstChoice is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final rawFinishReason = firstChoice["finish_reason"];
|
||||
if (rawFinishReason is String && rawFinishReason.isNotEmpty) {
|
||||
finishReason = rawFinishReason == "tool_calls"
|
||||
? "tool_use"
|
||||
: rawFinishReason;
|
||||
}
|
||||
|
||||
final delta = firstChoice["delta"];
|
||||
if (delta is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final content = delta["content"];
|
||||
if (content is String && content.isNotEmpty) {
|
||||
textBuffer.write(content);
|
||||
onTextDelta?.call(content);
|
||||
}
|
||||
|
||||
final toolCallDeltas = delta["tool_calls"];
|
||||
if (toolCallDeltas is! List) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final rawToolCall in toolCallDeltas) {
|
||||
if (rawToolCall is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final index = (rawToolCall["index"] as num?)?.toInt() ?? 0;
|
||||
final builder = toolCalls.putIfAbsent(
|
||||
index,
|
||||
() => _StreamingToolCallBuilder(),
|
||||
);
|
||||
|
||||
final toolCallId = rawToolCall["id"];
|
||||
if (toolCallId is String && toolCallId.isNotEmpty) {
|
||||
builder.id = toolCallId;
|
||||
}
|
||||
|
||||
final rawType = rawToolCall["type"];
|
||||
if (rawType is String && rawType.isNotEmpty) {
|
||||
builder.type = rawType;
|
||||
}
|
||||
|
||||
final function = rawToolCall["function"];
|
||||
if (function is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final name = function["name"];
|
||||
if (name is String && name.isNotEmpty) {
|
||||
builder.name = name;
|
||||
}
|
||||
|
||||
final arguments = function["arguments"];
|
||||
if (arguments is String && arguments.isNotEmpty) {
|
||||
builder.arguments.write(arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final contentBlocks = <Map<String, dynamic>>[];
|
||||
final text = textBuffer.toString();
|
||||
if (text.isNotEmpty) {
|
||||
contentBlocks.add(<String, dynamic>{"type": "text", "text": text});
|
||||
}
|
||||
|
||||
final orderedToolCalls = toolCalls.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
for (final entry in orderedToolCalls) {
|
||||
final builder = entry.value;
|
||||
contentBlocks.add(<String, dynamic>{
|
||||
"type": "tool_use",
|
||||
"id": builder.id,
|
||||
"name": builder.name,
|
||||
"input": builder.parsedArguments,
|
||||
final request = await _httpClient.openUrl("POST", url);
|
||||
headers.forEach((key, value) {
|
||||
request.headers.set(key, value);
|
||||
});
|
||||
}
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.write(jsonEncode(requestBody));
|
||||
|
||||
return ApiMessage(
|
||||
id: responseId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: contentBlocks,
|
||||
model: responseModel,
|
||||
stopReason: finishReason,
|
||||
usage: usage,
|
||||
inputTokens: (usage?["prompt_tokens"] as num?)?.toInt(),
|
||||
outputTokens: (usage?["completion_tokens"] as num?)?.toInt(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
final response = await request.close();
|
||||
if (response.statusCode >= 400) {
|
||||
final responseBody = await response.transform(utf8.decoder).join();
|
||||
print(
|
||||
"OpenRouter API error ${response.statusCode} for /chat/completions: $responseBody",
|
||||
);
|
||||
_handleErrorResponse(response.statusCode, responseBody);
|
||||
}
|
||||
|
||||
final responseStream = response
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
|
||||
await for (final line in responseStream) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
|
||||
final event = StreamingResponseParser.parseStreamLine(line);
|
||||
if (StreamingResponseParser.isDone(event)) {
|
||||
break;
|
||||
}
|
||||
if (event == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = event["id"];
|
||||
if (id is String && id.isNotEmpty) {
|
||||
responseId = id;
|
||||
}
|
||||
|
||||
final streamedModel = event["model"];
|
||||
if (streamedModel is String && streamedModel.isNotEmpty) {
|
||||
responseModel = streamedModel;
|
||||
}
|
||||
|
||||
final rawUsage = event["usage"];
|
||||
if (rawUsage is Map<String, dynamic>) {
|
||||
usage = rawUsage;
|
||||
}
|
||||
|
||||
final choices = event["choices"];
|
||||
if (choices is! List || choices.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final firstChoice = choices.first;
|
||||
if (firstChoice is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final rawFinishReason = firstChoice["finish_reason"];
|
||||
if (rawFinishReason is String && rawFinishReason.isNotEmpty) {
|
||||
finishReason = rawFinishReason == "tool_calls"
|
||||
? "tool_use"
|
||||
: rawFinishReason;
|
||||
}
|
||||
|
||||
final delta = firstChoice["delta"];
|
||||
if (delta is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final content = delta["content"];
|
||||
if (content is String && content.isNotEmpty) {
|
||||
hasVisibleOutput = true;
|
||||
textBuffer.write(content);
|
||||
onTextDelta?.call(content);
|
||||
}
|
||||
|
||||
final toolCallDeltas = delta["tool_calls"];
|
||||
if (toolCallDeltas is! List) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final rawToolCall in toolCallDeltas) {
|
||||
if (rawToolCall is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final index = (rawToolCall["index"] as num?)?.toInt() ?? 0;
|
||||
final builder = toolCalls.putIfAbsent(
|
||||
index,
|
||||
() => _StreamingToolCallBuilder(),
|
||||
);
|
||||
|
||||
final toolCallId = rawToolCall["id"];
|
||||
if (toolCallId is String && toolCallId.isNotEmpty) {
|
||||
builder.id = toolCallId;
|
||||
}
|
||||
|
||||
final rawType = rawToolCall["type"];
|
||||
if (rawType is String && rawType.isNotEmpty) {
|
||||
builder.type = rawType;
|
||||
}
|
||||
|
||||
final function = rawToolCall["function"];
|
||||
if (function is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final name = function["name"];
|
||||
if (name is String && name.isNotEmpty) {
|
||||
builder.name = name;
|
||||
}
|
||||
|
||||
final arguments = function["arguments"];
|
||||
if (arguments is String && arguments.isNotEmpty) {
|
||||
builder.arguments.write(arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final contentBlocks = <Map<String, dynamic>>[];
|
||||
final text = textBuffer.toString();
|
||||
if (text.isNotEmpty) {
|
||||
contentBlocks.add(<String, dynamic>{"type": "text", "text": text});
|
||||
}
|
||||
|
||||
final orderedToolCalls = toolCalls.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
for (final entry in orderedToolCalls) {
|
||||
final builder = entry.value;
|
||||
contentBlocks.add(<String, dynamic>{
|
||||
"type": "tool_use",
|
||||
"id": builder.id,
|
||||
"name": builder.name,
|
||||
"input": builder.parsedArguments,
|
||||
});
|
||||
}
|
||||
|
||||
return ApiMessage(
|
||||
id: responseId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: contentBlocks,
|
||||
model: responseModel,
|
||||
stopReason: finishReason,
|
||||
usage: usage,
|
||||
inputTokens: (usage?["prompt_tokens"] as num?)?.toInt(),
|
||||
outputTokens: (usage?["completion_tokens"] as num?)?.toInt(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
if (_config.enableLogging) {
|
||||
_log("[API STREAM ERROR] $e");
|
||||
}
|
||||
if (hasVisibleOutput) {
|
||||
throw StreamingRetryNotAllowedException(e);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
if (_config.enableLogging) {
|
||||
_log("[API STREAM ERROR] $e");
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}, canRetryAfterTimeout: () => !hasVisibleOutput);
|
||||
}
|
||||
|
||||
// List available models
|
||||
@@ -380,6 +394,8 @@ class OpenRouterClient {
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} on TimeoutException catch (_) {
|
||||
throw ApiTimeoutException(_config.requestTimeout);
|
||||
} catch (e) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
@@ -427,6 +443,98 @@ class OpenRouterClient {
|
||||
print("[OpenRouterClient] $message");
|
||||
}
|
||||
|
||||
Future<T> _withRetry<T>(
|
||||
Future<T> Function() operation, {
|
||||
bool Function()? canRetryAfterTimeout,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
|
||||
for (int attempt = 1; attempt <= _config.maxRetries + 1; attempt++) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation().timeout(_config.requestTimeout);
|
||||
} catch (error, stackTrace) {
|
||||
final retryableError =
|
||||
error is TimeoutException &&
|
||||
canRetryAfterTimeout != null &&
|
||||
!canRetryAfterTimeout()
|
||||
? StreamingRetryNotAllowedException(
|
||||
ApiTimeoutException(_config.requestTimeout),
|
||||
)
|
||||
: error;
|
||||
lastError = retryableError;
|
||||
if (!_shouldRetry(retryableError) || attempt > _config.maxRetries) {
|
||||
if (_config.enableLogging) {
|
||||
_log("[API RETRY STOP] $retryableError");
|
||||
}
|
||||
Error.throwWithStackTrace(retryableError, stackTrace);
|
||||
}
|
||||
|
||||
final delayMs = _getRetryDelayMs(attempt);
|
||||
print(
|
||||
"OpenRouter request failed (attempt $attempt/${_config.maxRetries + 1}), retrying in ${delayMs}ms: $retryableError",
|
||||
);
|
||||
_recreateHttpClient();
|
||||
await Future<void>.delayed(Duration(milliseconds: delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception("OpenRouter request failed");
|
||||
}
|
||||
|
||||
bool _shouldRetry(Object error) {
|
||||
if (error is RequestCancelledException) {
|
||||
return false;
|
||||
}
|
||||
if (error is StreamingRetryNotAllowedException) {
|
||||
return false;
|
||||
}
|
||||
if (error is ApiTimeoutException) {
|
||||
return true;
|
||||
}
|
||||
if (error is TimeoutException) {
|
||||
return true;
|
||||
}
|
||||
if (error is SocketException) {
|
||||
return true;
|
||||
}
|
||||
if (error is HttpException) {
|
||||
return true;
|
||||
}
|
||||
if (error is ApiException) {
|
||||
final statusCode = error.statusCode;
|
||||
if (statusCode == null) {
|
||||
return true;
|
||||
}
|
||||
if (statusCode == 408 || statusCode == 409 || statusCode == 429) {
|
||||
return true;
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int _getRetryDelayMs(int attempt) {
|
||||
final baseDelay = (_baseRetryDelayMs * (1 << (attempt - 1))).clamp(
|
||||
_baseRetryDelayMs,
|
||||
_maxRetryDelayMs,
|
||||
);
|
||||
final jitter = (Random().nextDouble() * 0.25 * baseDelay).round();
|
||||
return baseDelay + jitter;
|
||||
}
|
||||
|
||||
void _recreateHttpClient() {
|
||||
_httpClient.close(force: true);
|
||||
_httpClient = HttpClient();
|
||||
_httpClient.connectionTimeout = Duration(seconds: 600);
|
||||
}
|
||||
|
||||
void cancelActiveRequest() {
|
||||
_requestCancelled = true;
|
||||
_httpClient.close(force: true);
|
||||
@@ -465,6 +573,23 @@ class RequestCancelledException implements Exception {
|
||||
String toString() => "RequestCancelledException: Request cancelled by user";
|
||||
}
|
||||
|
||||
class StreamingRetryNotAllowedException implements Exception {
|
||||
const StreamingRetryNotAllowedException(this.cause);
|
||||
|
||||
final Object cause;
|
||||
|
||||
@override
|
||||
String toString() => cause.toString();
|
||||
}
|
||||
|
||||
class ApiTimeoutException extends ApiException {
|
||||
ApiTimeoutException(Duration timeout)
|
||||
: super("Request timed out after ${timeout.inSeconds} seconds", 408);
|
||||
|
||||
@override
|
||||
String toString() => "ApiTimeoutException: $message";
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
@@ -500,7 +625,8 @@ class RequestTooLargeException extends ApiException {
|
||||
class OpenRouterClientFactory {
|
||||
static Future<OpenRouterClient> create({
|
||||
String? apiKey,
|
||||
int maxRetries = 2,
|
||||
int maxRetries = 10,
|
||||
Duration requestTimeout = const Duration(seconds: 300),
|
||||
String? model,
|
||||
bool enableLogging = false,
|
||||
}) async {
|
||||
@@ -513,6 +639,7 @@ class OpenRouterClientFactory {
|
||||
final config = OpenRouterConfig(
|
||||
apiKey: resolvedApiKey,
|
||||
maxRetries: maxRetries,
|
||||
requestTimeout: requestTimeout,
|
||||
model: model,
|
||||
enableLogging: enableLogging,
|
||||
);
|
||||
|
||||
+81
-7
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'build_info.dart';
|
||||
import 'chat/repl_handler.dart';
|
||||
import 'command.dart';
|
||||
import 'daemon/daemon_manager.dart';
|
||||
import 'daemon/daemon_types.dart';
|
||||
@@ -15,6 +16,8 @@ import 'local_state.dart';
|
||||
import 'migration_assessment.dart';
|
||||
import 'runtime_state.dart';
|
||||
import 'services/cost_tracker.dart' as costTracker;
|
||||
import 'services/analytics_service.dart';
|
||||
import 'services/usage_tracker.dart';
|
||||
import 'session/conversation_history.dart';
|
||||
import 'session/session_store.dart';
|
||||
import 'session/session_types.dart';
|
||||
@@ -581,7 +584,29 @@ class _ClawdCli {
|
||||
this.runtimeStateStore,
|
||||
this.sessionState,
|
||||
this.hookRunner,
|
||||
);
|
||||
) {
|
||||
_toolRegistry = ToolRegistry();
|
||||
_toolRegistry.setSettings(settingsStore.settings);
|
||||
}
|
||||
|
||||
Future<void> _initializeServices() async {
|
||||
try {
|
||||
// Initialize analytics
|
||||
final analytics = AnalyticsService();
|
||||
final telemetryEnabled = settingsStore.settings.telemetry == 'true' ||
|
||||
settingsStore.settings.telemetry == null; // Default to enabled
|
||||
await analytics.initialize(enabled: telemetryEnabled);
|
||||
|
||||
// Initialize usage tracking
|
||||
final usage = UsageTracker();
|
||||
await usage.initialize(enabled: true);
|
||||
|
||||
// Log session start
|
||||
await analytics.logSession(sessionState.sessionId ?? 'unknown', 'start');
|
||||
} catch (e) {
|
||||
// Silently fail - services are optional
|
||||
}
|
||||
}
|
||||
|
||||
final CommandCatalog catalog;
|
||||
final RuntimeStateStore runtimeStateStore;
|
||||
@@ -590,9 +615,12 @@ class _ClawdCli {
|
||||
final HookRunner hookRunner;
|
||||
|
||||
// tool registry for direct tool invocations like "bash: echo hello"
|
||||
final ToolRegistry _toolRegistry = ToolRegistry();
|
||||
late final ToolRegistry _toolRegistry;
|
||||
|
||||
Future<CommandResult> run(List<String> args) async {
|
||||
// Initialize services
|
||||
await _initializeServices();
|
||||
|
||||
if (_isVersionFastPath(args)) {
|
||||
stdout.writeln(BuildInfo.versionDisplay);
|
||||
return const CommandResult();
|
||||
@@ -661,10 +689,11 @@ class _ClawdCli {
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
stderr.writeln(
|
||||
'Free-form prompt execution is not ported yet. Start the REPL with no args or use a known command.',
|
||||
// Free-form prompt: send to model via REPL handler
|
||||
return await _handleFreeFormPrompt(
|
||||
input: tokens.join(' '),
|
||||
interactive: interactive,
|
||||
);
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
Future<CommandResult> _execute(
|
||||
@@ -688,6 +717,9 @@ class _ClawdCli {
|
||||
);
|
||||
});
|
||||
|
||||
// Log command execution
|
||||
await _logCommandExecution(command.name, args);
|
||||
|
||||
// run before-command hooks
|
||||
await hookRunner.runHooksForKind(
|
||||
HookKind.userPromptSubmit,
|
||||
@@ -722,6 +754,15 @@ class _ClawdCli {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _logCommandExecution(String commandName, List<String> args) async {
|
||||
try {
|
||||
final analytics = AnalyticsService();
|
||||
await analytics.logCommand(commandName, args: args, sessionId: sessionState.sessionId);
|
||||
} catch (e) {
|
||||
// Silently fail - analytics is optional
|
||||
}
|
||||
}
|
||||
|
||||
Future<CommandResult> _executePortedCommand(
|
||||
String name,
|
||||
List<String> args, {
|
||||
@@ -876,6 +917,39 @@ class _ClawdCli {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<CommandResult> _handleFreeFormPrompt({
|
||||
required String input,
|
||||
required bool interactive,
|
||||
}) async {
|
||||
if (!interactive) {
|
||||
stderr.writeln('Free-form prompts are only supported in interactive mode (REPL).');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
try {
|
||||
final handler = ReplHandler(
|
||||
settings: settingsStore.settings,
|
||||
sessionId: sessionState.sessionId ?? 'unknown',
|
||||
workingDirectory: sessionState.workingDirectory,
|
||||
);
|
||||
|
||||
stdout.writeln('');
|
||||
await handler.executePrompt(
|
||||
userInput: input,
|
||||
streaming: true,
|
||||
);
|
||||
stdout.writeln('');
|
||||
|
||||
return const CommandResult();
|
||||
} catch (e, st) {
|
||||
stderr.writeln('Error: $e');
|
||||
if (settingsStore.settings.privacyLevel == 'debug') {
|
||||
stderr.writeln(st);
|
||||
}
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2292,7 +2366,7 @@ Future<CommandResult> _runResume(
|
||||
) async {
|
||||
final query = args.join(' ').trim();
|
||||
|
||||
final sessions = await SessionStore.instance.listSessions();
|
||||
final sessions = await SessionStore.instance.listSessionsForProject(context.workingDirectory);
|
||||
|
||||
if (sessions.isEmpty) {
|
||||
context.writeLine("No saved sessions found.");
|
||||
@@ -2329,7 +2403,7 @@ Future<CommandResult> _runResume(
|
||||
|
||||
// if exactly one match and it was a direct lookup - load it
|
||||
if (filtered.length == 1 && query.isNotEmpty) {
|
||||
final loaded = await SessionStore.instance.loadSession(filtered.first.id);
|
||||
final loaded = await SessionStore.instance.loadSession(filtered.first.id, workingDirectory: context.workingDirectory);
|
||||
if (loaded != null) {
|
||||
_history.setSession(loaded);
|
||||
context.sessionState.sessionName = loaded.name;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import "../api/openrouter_client.dart";
|
||||
|
||||
const _advisorSystemPrompt =
|
||||
"You are an advisor reviewing an AI agent's work in progress. "
|
||||
"You will be shown the full conversation history including tool calls and results. "
|
||||
"Your job is to give concise, actionable guidance: identify mistakes, "
|
||||
"suggest better approaches, flag assumptions that need verifying, or confirm "
|
||||
"the agent is on the right track. Be direct and specific.";
|
||||
|
||||
const advisorToolDescription =
|
||||
"Call the advisor model for a second opinion on your current approach. "
|
||||
"Takes no parameters — your full conversation history is forwarded automatically. "
|
||||
"Call BEFORE committing to a significant approach, BEFORE declaring done, or when stuck.";
|
||||
|
||||
class AdvisorService {
|
||||
|
||||
Future<String> run({
|
||||
required String advisorModel,
|
||||
required String apiKey,
|
||||
required List<Map<String, dynamic>> conversationSoFar,
|
||||
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
|
||||
void Function(String toolName, String result)? onToolResult,
|
||||
}) async {
|
||||
onToolCall?.call("Advisor", {"model": advisorModel});
|
||||
|
||||
OpenRouterClient? client;
|
||||
try {
|
||||
client = OpenRouterClient(
|
||||
config: OpenRouterConfig(apiKey: apiKey),
|
||||
);
|
||||
|
||||
final response = await client.createMessage(
|
||||
model: advisorModel,
|
||||
maxTokens: 2048,
|
||||
messages: conversationSoFar,
|
||||
system: _advisorSystemPrompt,
|
||||
);
|
||||
|
||||
final text = response.content
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.where((b) => b["type"] == "text")
|
||||
.map((b) => b["text"] as String? ?? "")
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
final result = text.isEmpty ? "Advisor returned no guidance." : text;
|
||||
onToolResult?.call("Advisor", result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
final err = "Advisor call failed: $e";
|
||||
onToolResult?.call("Advisor", err);
|
||||
return err;
|
||||
} finally {
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// REPL handler for free-form prompts
|
||||
// Bridges user input → ToolLoopService → model → tool execution
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../api/openrouter_client.dart';
|
||||
import '../chat/tool_loop_service.dart';
|
||||
import '../local_state.dart';
|
||||
import '../services/cost_tracker.dart' as costTracker;
|
||||
import '../utils/model_cost.dart';
|
||||
|
||||
class ReplHandler {
|
||||
ReplHandler({
|
||||
required this.settings,
|
||||
required this.sessionId,
|
||||
required this.workingDirectory,
|
||||
});
|
||||
|
||||
final LocalSettings settings;
|
||||
final String sessionId;
|
||||
final String workingDirectory;
|
||||
|
||||
// Conversation history for this REPL session
|
||||
final List<Map<String, dynamic>> _conversationHistory = [];
|
||||
|
||||
/// Execute a free-form prompt in the REPL
|
||||
/// Returns the assistant's text response
|
||||
Future<String> executePrompt({
|
||||
required String userInput,
|
||||
bool streaming = true,
|
||||
}) async {
|
||||
// Get API configuration
|
||||
final apiKey = _resolveApiKey();
|
||||
if (apiKey.isEmpty) {
|
||||
return 'Error: No API key configured. Set OPENROUTER_API_KEY or USE_ANTHROPIC with ANTHROPIC_API_KEY.';
|
||||
}
|
||||
|
||||
final model = settings.model ?? _getDefaultModel();
|
||||
if (model.isEmpty) {
|
||||
return 'Error: No model configured. Use /model to set one.';
|
||||
}
|
||||
|
||||
// Create API client
|
||||
final client = OpenRouterClient(
|
||||
config: OpenRouterConfig(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
enableLogging: false,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Create tool loop service
|
||||
final toolLoop = ToolLoopService();
|
||||
|
||||
// Run the tool loop
|
||||
final result = await toolLoop.runTurn(
|
||||
client: client,
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
getSettings: () => settings,
|
||||
apiMessages: _conversationHistory,
|
||||
userText: userInput,
|
||||
workingDirectory: workingDirectory,
|
||||
onToolCall: (toolName, input) {
|
||||
stderr.writeln('→ Calling $toolName');
|
||||
},
|
||||
onToolResult: (toolName, result) {
|
||||
stderr.writeln('← $toolName returned: ${result.substring(0, 100)}${result.length > 100 ? '...' : ''}');
|
||||
},
|
||||
onAssistantTextDelta: (delta) {
|
||||
if (streaming) {
|
||||
stdout.write(delta);
|
||||
}
|
||||
},
|
||||
onAssistantMessageComplete: () {
|
||||
if (streaming) {
|
||||
stdout.writeln();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update conversation history
|
||||
_conversationHistory.addAll(result.apiMessages);
|
||||
|
||||
// Track costs
|
||||
if (result.response.inputTokens != null && result.response.outputTokens != null) {
|
||||
final cost = calculateUSDCost(
|
||||
model,
|
||||
TokenUsage(
|
||||
inputTokens: result.response.inputTokens!,
|
||||
outputTokens: result.response.outputTokens!,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
webSearchRequests: result.webSearchRequests,
|
||||
),
|
||||
);
|
||||
|
||||
costTracker.addToTotalSessionCost(
|
||||
model: model,
|
||||
cost: cost,
|
||||
inputTokens: result.response.inputTokens!,
|
||||
outputTokens: result.response.outputTokens!,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
webSearchRequests: result.webSearchRequests,
|
||||
webFetchRequests: result.webFetchRequests,
|
||||
);
|
||||
}
|
||||
|
||||
return result.responseText;
|
||||
} catch (e) {
|
||||
return 'Error: ${e.toString()}';
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get conversation history for session
|
||||
List<Map<String, dynamic>> getHistory() => List.from(_conversationHistory);
|
||||
|
||||
/// Clear conversation history
|
||||
void clearHistory() => _conversationHistory.clear();
|
||||
|
||||
String _resolveApiKey() {
|
||||
// Check settings first if openRouterApiKey is configured
|
||||
if (settings.openRouterApiKey?.isNotEmpty ?? false) {
|
||||
return settings.openRouterApiKey!;
|
||||
}
|
||||
|
||||
final env = Platform.environment;
|
||||
|
||||
// Try OpenRouter first (vendor-neutral preferred)
|
||||
if ((env['USE_OPENROUTER'] ?? '').toLowerCase() == 'true' ||
|
||||
(env['OPENROUTER_API_KEY']?.isNotEmpty ?? false)) {
|
||||
return env['OPENROUTER_API_KEY'] ?? '';
|
||||
}
|
||||
|
||||
// Try Anthropic
|
||||
if ((env['USE_ANTHROPIC'] ?? '').toLowerCase() == 'true') {
|
||||
return env['ANTHROPIC_API_KEY'] ?? env['CLAUDE_API_KEY'] ?? '';
|
||||
}
|
||||
|
||||
// Default: check all sources
|
||||
return env['OPENROUTER_API_KEY'] ??
|
||||
env['ANTHROPIC_API_KEY'] ??
|
||||
env['CLAUDE_API_KEY'] ??
|
||||
env['CLAUDE_CODE_API_KEY'] ??
|
||||
'';
|
||||
}
|
||||
|
||||
String _getDefaultModel() {
|
||||
// Check settings first
|
||||
if (settings.model?.isNotEmpty ?? false) {
|
||||
return settings.model!;
|
||||
}
|
||||
|
||||
final env = Platform.environment;
|
||||
|
||||
// If USE_OPENROUTER is set, default to an OpenRouter model
|
||||
if ((env['USE_OPENROUTER'] ?? '').toLowerCase() == 'true') {
|
||||
return 'openrouter/auto'; // OpenRouter will pick best available
|
||||
}
|
||||
|
||||
// If USE_ANTHROPIC is set or ANTHROPIC_API_KEY exists, use Claude
|
||||
if ((env['USE_ANTHROPIC'] ?? '').toLowerCase() == 'true' ||
|
||||
(env['ANTHROPIC_API_KEY']?.isNotEmpty ?? false)) {
|
||||
return 'claude-opus-4-1';
|
||||
}
|
||||
|
||||
// Check what API keys are available
|
||||
if ((env['OPENROUTER_API_KEY']?.isNotEmpty ?? false)) {
|
||||
return 'openrouter/auto';
|
||||
}
|
||||
|
||||
if ((env['ANTHROPIC_API_KEY']?.isNotEmpty ?? false) ||
|
||||
(env['CLAUDE_API_KEY']?.isNotEmpty ?? false)) {
|
||||
return 'claude-opus-4-1';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,15 @@ import "package:path/path.dart" as path;
|
||||
|
||||
import "../api/api_types.dart";
|
||||
import "../api/openrouter_client.dart";
|
||||
import "../hooks/hook_runner.dart";
|
||||
import "../hooks/hook_types.dart";
|
||||
import "../permissions/permission_manager.dart";
|
||||
import "../permissions/permission_types.dart";
|
||||
import "advisor_service.dart";
|
||||
import "../local_state.dart";
|
||||
import "../api/response_parser.dart";
|
||||
import "../services/tool_telemetry_service.dart";
|
||||
import "../system_prompt/claude_md_loader.dart";
|
||||
import "../system_prompt/system_prompt_builder.dart";
|
||||
import "../tools/tool_registry.dart";
|
||||
|
||||
@@ -14,12 +22,16 @@ class ToolLoopResult {
|
||||
required this.responseText,
|
||||
required this.response,
|
||||
required this.finalResponseWasStreamed,
|
||||
required this.webSearchRequests,
|
||||
required this.webFetchRequests,
|
||||
});
|
||||
|
||||
final List<Map<String, dynamic>> apiMessages;
|
||||
final String responseText;
|
||||
final ApiMessage response;
|
||||
final bool finalResponseWasStreamed;
|
||||
final int webSearchRequests;
|
||||
final int webFetchRequests;
|
||||
}
|
||||
|
||||
class ToolLoopException implements Exception {
|
||||
@@ -38,20 +50,31 @@ class ToolLoopException implements Exception {
|
||||
}
|
||||
|
||||
class ToolLoopService {
|
||||
ToolLoopService() : _toolRegistry = ToolRegistry();
|
||||
ToolLoopService({HookRunner? hookRunner})
|
||||
: _toolRegistry = ToolRegistry(),
|
||||
_toolTelemetryClient = createToolTelemetryClient(),
|
||||
_hookRunner = hookRunner,
|
||||
_advisorService = AdvisorService();
|
||||
|
||||
final ToolRegistry _toolRegistry;
|
||||
final ToolTelemetryClient _toolTelemetryClient;
|
||||
final HookRunner? _hookRunner;
|
||||
final AdvisorService _advisorService;
|
||||
|
||||
Future<ToolLoopResult> runTurn({
|
||||
required OpenRouterClient client,
|
||||
required String model,
|
||||
required String apiKey,
|
||||
required LocalSettings Function() getSettings,
|
||||
required List<Map<String, dynamic>> apiMessages,
|
||||
required String userText,
|
||||
String? workingDirectory,
|
||||
String? advisorModel,
|
||||
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
|
||||
void Function(String toolName, String result)? onToolResult,
|
||||
void Function(String delta)? onAssistantTextDelta,
|
||||
void Function()? onAssistantMessageComplete,
|
||||
Future<PermissionDecision> Function(String toolName, Map<String, dynamic> input)? onPermissionRequired,
|
||||
}) async {
|
||||
final updatedMessages = List<Map<String, dynamic>>.from(apiMessages)
|
||||
..add(<String, dynamic>{"role": "user", "content": userText});
|
||||
@@ -65,8 +88,8 @@ class ToolLoopService {
|
||||
model: model,
|
||||
maxTokens: 4096,
|
||||
messages: updatedMessages,
|
||||
system: _buildSystemPrompt(workingDirectory),
|
||||
tools: _buildToolDefinitions(),
|
||||
system: await _buildSystemPrompt(workingDirectory),
|
||||
tools: _buildToolDefinitions(advisorModel: advisorModel),
|
||||
toolChoice: "auto",
|
||||
onTextDelta: (delta) {
|
||||
streamedTextThisIteration = true;
|
||||
@@ -91,17 +114,72 @@ class ToolLoopService {
|
||||
: responseText,
|
||||
response: lastResponse,
|
||||
finalResponseWasStreamed: streamedTextThisIteration,
|
||||
webSearchRequests: lastResponse.webSearchRequests ?? 0,
|
||||
webFetchRequests: lastResponse.webFetchRequests ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
for (final toolUse in toolUses) {
|
||||
// advisor is handled separately — not via the tool registry
|
||||
if (toolUse.name == "Advisor") {
|
||||
final advisorResult = await _advisorService.run(
|
||||
advisorModel: advisorModel!,
|
||||
apiKey: apiKey,
|
||||
conversationSoFar: List<Map<String, dynamic>>.from(updatedMessages),
|
||||
onToolCall: onToolCall,
|
||||
onToolResult: onToolResult,
|
||||
);
|
||||
updatedMessages.add(<String, dynamic>{
|
||||
"role": "tool",
|
||||
"tool_call_id": toolUse.id,
|
||||
"content": advisorResult,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
final currentSettings = getSettings();
|
||||
final normalizedInput = _normalizeToolInput(
|
||||
toolName: toolUse.name,
|
||||
input: toolUse.input,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
settings: currentSettings,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
|
||||
// check permissions before executing
|
||||
final permManager = PermissionManager(currentSettings);
|
||||
if (permManager.shouldAskForPermission(toolUse.name, normalizedInput, workingDirectory)) {
|
||||
onToolCall?.call(toolUse.name, normalizedInput);
|
||||
|
||||
PermissionDecision decision;
|
||||
if (onPermissionRequired != null) {
|
||||
decision = await onPermissionRequired(toolUse.name, normalizedInput);
|
||||
} else {
|
||||
decision = PermissionDecision.reject;
|
||||
}
|
||||
|
||||
if (decision == PermissionDecision.reject) {
|
||||
const denied = "Permission denied by user.";
|
||||
onToolResult?.call(toolUse.name, denied);
|
||||
updatedMessages.add(<String, dynamic>{
|
||||
"role": "tool",
|
||||
"tool_call_id": toolUse.id,
|
||||
"content": denied,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// allowOnce or allowAlways — fall through to execute
|
||||
}
|
||||
|
||||
onToolCall?.call(toolUse.name, normalizedInput);
|
||||
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.preToolUse,
|
||||
targetName: toolUse.name,
|
||||
input: normalizedInput,
|
||||
);
|
||||
|
||||
final toolResult = await _executeTool(
|
||||
toolUse: toolUse,
|
||||
normalizedInput: normalizedInput,
|
||||
@@ -133,15 +211,55 @@ class ToolLoopService {
|
||||
required ToolUse toolUse,
|
||||
required Map<String, dynamic> normalizedInput,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
print(
|
||||
"Executing tool ${toolUse.name} with input: ${jsonEncode(normalizedInput)}",
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await _toolRegistry.execute(toolUse.name, normalizedInput);
|
||||
final success = !result.startsWith("Error");
|
||||
await _toolTelemetryClient.recordToolCall(
|
||||
toolName: toolUse.name,
|
||||
success: success,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.postToolUse,
|
||||
targetName: toolUse.name,
|
||||
input: normalizedInput,
|
||||
output: result,
|
||||
exitCode: 0,
|
||||
);
|
||||
} else {
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.postToolUseFailure,
|
||||
targetName: toolUse.name,
|
||||
input: normalizedInput,
|
||||
output: result,
|
||||
exitCode: 1,
|
||||
);
|
||||
}
|
||||
|
||||
print("Tool ${toolUse.name} completed");
|
||||
return result;
|
||||
} catch (error, stackTrace) {
|
||||
await _toolTelemetryClient.recordToolCall(
|
||||
toolName: toolUse.name,
|
||||
success: false,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
);
|
||||
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.postToolUseFailure,
|
||||
targetName: toolUse.name,
|
||||
input: normalizedInput,
|
||||
output: error.toString(),
|
||||
exitCode: 1,
|
||||
);
|
||||
|
||||
print("Tool ${toolUse.name} failed: $error");
|
||||
print(stackTrace);
|
||||
return "Error executing ${toolUse.name}: $error";
|
||||
@@ -151,34 +269,53 @@ class ToolLoopService {
|
||||
Map<String, dynamic> _normalizeToolInput({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> input,
|
||||
required String apiKey,
|
||||
required String model,
|
||||
required LocalSettings settings,
|
||||
String? workingDirectory,
|
||||
}) {
|
||||
final normalized = Map<String, dynamic>.from(input);
|
||||
final cwd = workingDirectory?.trim();
|
||||
|
||||
if (cwd == null || cwd.isEmpty) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
normalized["cwd"] = cwd;
|
||||
if (cwd != null && cwd.isNotEmpty) {
|
||||
normalized["cwd"] = cwd;
|
||||
}
|
||||
break;
|
||||
case "Read":
|
||||
case "Edit":
|
||||
case "Write":
|
||||
final rawPath = normalized["file_path"];
|
||||
if (rawPath is String && rawPath.isNotEmpty) {
|
||||
normalized["file_path"] = _resolvePath(rawPath, cwd);
|
||||
if (cwd != null && cwd.isNotEmpty) {
|
||||
final rawPath = normalized["file_path"];
|
||||
if (rawPath is String && rawPath.isNotEmpty) {
|
||||
normalized["file_path"] = _resolvePath(rawPath, cwd);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Glob":
|
||||
case "Grep":
|
||||
final rawPath = normalized["path"];
|
||||
if (rawPath is String && rawPath.isNotEmpty) {
|
||||
normalized["path"] = _resolvePath(rawPath, cwd);
|
||||
} else {
|
||||
normalized["path"] = cwd;
|
||||
if (cwd != null && cwd.isNotEmpty) {
|
||||
final rawPath = normalized["path"];
|
||||
if (rawPath is String && rawPath.isNotEmpty) {
|
||||
normalized["path"] = _resolvePath(rawPath, cwd);
|
||||
} else {
|
||||
normalized["path"] = cwd;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "WebSearch":
|
||||
case "WebFetch":
|
||||
normalized["_api_key"] = apiKey;
|
||||
normalized["_model"] = model;
|
||||
normalized["_permission_mode"] = settings.permissionMode;
|
||||
normalized["_allow_rules"] = settings.alwaysAllowRules;
|
||||
normalized["_ask_rules"] = settings.alwaysAskRules;
|
||||
normalized["_deny_rules"] = settings.alwaysDenyRules;
|
||||
break;
|
||||
case "ExecuteTask":
|
||||
if (cwd != null && cwd.isNotEmpty) {
|
||||
normalized["working_directory"] = cwd;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -228,8 +365,19 @@ class ToolLoopService {
|
||||
return message;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildToolDefinitions() {
|
||||
List<Map<String, dynamic>> _buildToolDefinitions({String? advisorModel}) {
|
||||
return <Map<String, dynamic>>[
|
||||
|
||||
if (advisorModel != null && advisorModel.isNotEmpty)
|
||||
_functionTool(
|
||||
name: "Advisor",
|
||||
description:
|
||||
"Call the advisor model for a second opinion on your current approach. "
|
||||
"Takes no parameters — your full conversation history is forwarded automatically. "
|
||||
"Call BEFORE committing to a significant approach, BEFORE declaring done, or when stuck.",
|
||||
properties: <String, dynamic>{},
|
||||
required: const <String>[],
|
||||
),
|
||||
_functionTool(
|
||||
name: "Bash",
|
||||
description:
|
||||
@@ -284,6 +432,44 @@ class ToolLoopService {
|
||||
},
|
||||
required: const <String>["pattern"],
|
||||
),
|
||||
_functionTool(
|
||||
name: "WebSearch",
|
||||
description:
|
||||
"Search the web for current information. Supports optional allowed_domains or blocked_domains filters and returns a cited summary.",
|
||||
properties: <String, dynamic>{
|
||||
"query": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "The web search query to run.",
|
||||
},
|
||||
"allowed_domains": <String, dynamic>{
|
||||
"type": "array",
|
||||
"items": <String, dynamic>{"type": "string"},
|
||||
"description": "Optional list of domains to include.",
|
||||
},
|
||||
"blocked_domains": <String, dynamic>{
|
||||
"type": "array",
|
||||
"items": <String, dynamic>{"type": "string"},
|
||||
"description": "Optional list of domains to exclude.",
|
||||
},
|
||||
},
|
||||
required: const <String>["query"],
|
||||
),
|
||||
_functionTool(
|
||||
name: "WebFetch",
|
||||
description:
|
||||
"Fetch content from a URL, extract readable text, and answer a prompt about that page.",
|
||||
properties: <String, dynamic>{
|
||||
"url": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "The fully formed URL to fetch.",
|
||||
},
|
||||
"prompt": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "What information to extract from the fetched page.",
|
||||
},
|
||||
},
|
||||
required: const <String>["url", "prompt"],
|
||||
),
|
||||
_functionTool(
|
||||
name: "Read",
|
||||
description: "Read a file from the project with line numbers.",
|
||||
@@ -341,6 +527,40 @@ class ToolLoopService {
|
||||
},
|
||||
required: const <String>["file_path", "content"],
|
||||
),
|
||||
_functionTool(
|
||||
name: "ExecuteTask",
|
||||
description:
|
||||
"Execute a task as a background process with real process management.",
|
||||
properties: <String, dynamic>{
|
||||
"action": <String, dynamic>{
|
||||
"type": "string",
|
||||
"enum": <String>["execute", "status", "result", "cancel", "list"],
|
||||
"description": "Action to perform.",
|
||||
},
|
||||
"task_id": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "Task identifier (required for execute).",
|
||||
},
|
||||
"command": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "Command to execute (required for execute).",
|
||||
},
|
||||
"arguments": <String, dynamic>{
|
||||
"type": "array",
|
||||
"items": <String, dynamic>{"type": "string"},
|
||||
"description": "Command arguments.",
|
||||
},
|
||||
"process_id": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "Process identifier (for status/result/cancel).",
|
||||
},
|
||||
"force": <String, dynamic>{
|
||||
"type": "boolean",
|
||||
"description": "Force kill when canceling.",
|
||||
},
|
||||
},
|
||||
required: const <String>["action"],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -365,7 +585,7 @@ class ToolLoopService {
|
||||
};
|
||||
}
|
||||
|
||||
String _buildSystemPrompt(String? workingDirectory) {
|
||||
Future<String> _buildSystemPrompt(String? workingDirectory) async {
|
||||
final cwd = workingDirectory?.trim();
|
||||
final appendPrompt = [
|
||||
if (cwd == null || cwd.isEmpty)
|
||||
@@ -373,13 +593,22 @@ class ToolLoopService {
|
||||
else
|
||||
"The active working directory is: $cwd",
|
||||
"You have access to tools for shell commands, file globbing, grep search, file reads, exact edits, and file writes.",
|
||||
"You also have a WebSearch tool for up-to-date external information; when you use it, include a Sources section with markdown links in your final answer.",
|
||||
"You also have a WebFetch tool for reading a specific public URL and answering questions about that page.",
|
||||
"If MCP-provided web search or web fetch tools are available in your tool list, prefer them over the built-in WebSearch and WebFetch tools.",
|
||||
"When the user asks about files, code, project structure, configuration, or repository contents, use the tools instead of guessing.",
|
||||
"If the user asks you to inspect the project structure, start by using Glob or Bash to inspect the filesystem.",
|
||||
"Do not claim you cannot access the project when tools are available.",
|
||||
"Keep answers concise and grounded in tool results.",
|
||||
].join("\n");
|
||||
|
||||
return buildDefaultSystemPrompt(appendSystemPrompt: appendPrompt);
|
||||
final memoryFiles = await getMemoryFiles(workingDirectory);
|
||||
final claudeMd = getClaudeMds(memoryFiles);
|
||||
|
||||
return buildDefaultSystemPrompt(
|
||||
appendSystemPrompt: appendPrompt,
|
||||
claudeMd: claudeMd.isEmpty ? null : claudeMd,
|
||||
);
|
||||
}
|
||||
|
||||
String _buildEmptyAssistantFallback(ApiMessage response) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Constants for Claude Code
|
||||
// Vendor-neutral abstractions for remote services - ACTUALLY INTEGRATED
|
||||
|
||||
/// Base endpoint for hosted services
|
||||
/// Replace anthropic.com specific endpoints with this vendor-neutral constant
|
||||
const String kHostEndpoint = String.fromEnvironment(
|
||||
'CLAWED_HOST_ENDPOINT',
|
||||
defaultValue: '', // Empty means use local fallback
|
||||
);
|
||||
|
||||
/// Environment variable for overriding host endpoint
|
||||
const String kHostEndpointEnvVar = 'CLAWED_HOST_ENDPOINT';
|
||||
|
||||
/// Check if remote services are available
|
||||
bool areRemoteServicesAvailable() {
|
||||
return kHostEndpoint.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get configured host endpoint with validation
|
||||
String getHostEndpoint() {
|
||||
return kHostEndpoint;
|
||||
}
|
||||
|
||||
/// Check if a feature should use remote service or local fallback
|
||||
bool shouldUseRemoteService(String feature) {
|
||||
if (!areRemoteServicesAvailable()) return false;
|
||||
|
||||
// For now, all features can use remote if endpoint is configured
|
||||
return true;
|
||||
}
|
||||
|
||||
/// API paths for remote services (relative to kHostEndpoint)
|
||||
class ApiPaths {
|
||||
static const String sessions = '/api/v1/sessions';
|
||||
static const String analytics = '/api/v1/analytics';
|
||||
static const String usage = '/api/v1/usage';
|
||||
static const String config = '/api/v1/config';
|
||||
static const String auth = '/api/v1/auth';
|
||||
|
||||
/// Get session URL for a given session ID
|
||||
static String sessionUrl(String sessionId) => '$sessions/$sessionId';
|
||||
|
||||
/// Get session worker URL for a given session ID
|
||||
static String sessionWorkerUrl(String sessionId) => '$sessions/$sessionId/worker';
|
||||
}
|
||||
|
||||
/// Get full URL for a remote service
|
||||
String getRemoteServiceUrl(String path) {
|
||||
final endpoint = getHostEndpoint();
|
||||
if (endpoint.isEmpty) {
|
||||
throw Exception('Remote services not configured');
|
||||
}
|
||||
return '$endpoint$path';
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Configuration constants for Claude Code Dart migration
|
||||
// Vendor-neutral configuration with fallbacks
|
||||
|
||||
/// Environment variable configuration keys
|
||||
class Config {
|
||||
/// Whether to use custom endpoints
|
||||
static const bool useCustomEndpoints = false;
|
||||
|
||||
/// API endpoint from environment (vendor-neutral)
|
||||
static const String? apiEndpoint = null;
|
||||
|
||||
/// Default endpoints when not overridden
|
||||
static const String defaultApiEndpoint = String.fromEnvironment(
|
||||
'CLAWED_HOST_ENDPOINT',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
/// Model Configuration (vendor-neutral)
|
||||
static const String defaultMainModel = String.fromEnvironment(
|
||||
'CLAWED_DEFAULT_MODEL',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
/// Model aliases mapping (configurable)
|
||||
static final Map<String, String> modelAliases = {
|
||||
// Can be populated from config file
|
||||
};
|
||||
|
||||
/// Model context window sizes (configurable)
|
||||
static final Map<String, int> modelContextWindows = {
|
||||
// Can be populated from config file
|
||||
};
|
||||
|
||||
/// Home directory for configuration
|
||||
static String get homeDirectory => '~/.clawd_code';
|
||||
|
||||
/// Configuration file paths
|
||||
static String get configPath => '${homeDirectory}/config';
|
||||
static String get settingsFilePath => '${configPath}/settings.json';
|
||||
|
||||
/// Parse boolean environment variable
|
||||
static bool parseBoolEnv(String value) {
|
||||
final lowerValue = value.toLowerCase();
|
||||
return lowerValue == 'true' || lowerValue == '1' || lowerValue == 'yes';
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ String getCoordinatorSystemPrompt() {
|
||||
? "Workers have access to Bash, Read, and Edit tools, plus MCP tools from configured MCP servers."
|
||||
: "Workers have access to standard tools, MCP tools from configured MCP servers, and project skills via the Skill tool. Delegate skill invocations (e.g. /commit, /verify) to workers.";
|
||||
|
||||
return """You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.
|
||||
return """You are The Agency, an AI assistant that orchestrates software engineering tasks across multiple workers.
|
||||
|
||||
## 1. Your Role
|
||||
|
||||
|
||||
@@ -180,6 +180,34 @@ class LocalSettings {
|
||||
);
|
||||
}
|
||||
|
||||
// Merges this (base) with an override layer.
|
||||
// For nullable fields: override wins only if non-null.
|
||||
// For list fields: override wins if non-empty.
|
||||
// For non-nullable fields with defaults: override wins if different from its default.
|
||||
LocalSettings mergeWith(LocalSettings? override) {
|
||||
if (override == null) return this;
|
||||
|
||||
return LocalSettings(
|
||||
advisorModel: override.advisorModel ?? advisorModel,
|
||||
alwaysAllowRules: override.alwaysAllowRules.isNotEmpty ? override.alwaysAllowRules : alwaysAllowRules,
|
||||
alwaysAskRules: override.alwaysAskRules.isNotEmpty ? override.alwaysAskRules : alwaysAskRules,
|
||||
alwaysDenyRules: override.alwaysDenyRules.isNotEmpty ? override.alwaysDenyRules : alwaysDenyRules,
|
||||
editorMode: override.editorMode != 'normal' ? override.editorMode : editorMode,
|
||||
effortLevel: override.effortLevel ?? effortLevel,
|
||||
fastMode: override.fastMode ? true : fastMode,
|
||||
hooks: override.hooks ?? hooks,
|
||||
mcpServers: override.mcpServers ?? mcpServers,
|
||||
model: override.model ?? model,
|
||||
openRouterApiKey: override.openRouterApiKey ?? openRouterApiKey,
|
||||
outputStyle: override.outputStyle ?? outputStyle,
|
||||
permissionMode: override.permissionMode != 'default' ? override.permissionMode : permissionMode,
|
||||
privacyLevel: override.privacyLevel ?? privacyLevel,
|
||||
statusLinePrompt: override.statusLinePrompt ?? statusLinePrompt,
|
||||
telemetry: override.telemetry ?? telemetry,
|
||||
theme: override.theme != 'dark' ? override.theme : theme,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'advisorModel': advisorModel,
|
||||
@@ -206,7 +234,8 @@ class LocalSettings {
|
||||
class SessionState {
|
||||
SessionState({required this.workingDirectory, String? effortValue})
|
||||
: effortValue = effortValue,
|
||||
startedAt = DateTime.now().toUtc();
|
||||
startedAt = DateTime.now().toUtc(),
|
||||
sessionId = _generateSessionId();
|
||||
|
||||
String? effortValue;
|
||||
int commandsExecuted = 0;
|
||||
@@ -229,6 +258,13 @@ class SessionState {
|
||||
|
||||
final DateTime startedAt;
|
||||
final String workingDirectory;
|
||||
final String sessionId;
|
||||
|
||||
static String _generateSessionId() {
|
||||
final now = DateTime.now();
|
||||
final rnd = (now.millisecondsSinceEpoch % 1000000).toString().padLeft(6, '0');
|
||||
return 'sess_${now.millisecondsSinceEpoch}_$rnd';
|
||||
}
|
||||
|
||||
String get planFilePath {
|
||||
return joinPath(getPlansDirectoryPath(), 'active-plan.md');
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../local_state.dart';
|
||||
|
||||
/// Manages tool permissions and access control
|
||||
class PermissionManager {
|
||||
final LocalSettings settings;
|
||||
|
||||
PermissionManager(this.settings);
|
||||
|
||||
/// Check if a tool is allowed to run
|
||||
bool isToolAllowed(String toolName, [String? toolArgs]) {
|
||||
// Check bypass mode
|
||||
if (settings.permissionMode == 'bypassPermissions') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check explicit deny rules
|
||||
for (final rule in settings.alwaysDenyRules) {
|
||||
if (_matchesRule(toolName, toolArgs, rule)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check explicit allow rules
|
||||
for (final rule in settings.alwaysAllowRules) {
|
||||
if (_matchesRule(toolName, toolArgs, rule)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check ask rules (requires user confirmation)
|
||||
for (final rule in settings.alwaysAskRules) {
|
||||
if (_matchesRule(toolName, toolArgs, rule)) {
|
||||
// In interactive mode, we would ask user
|
||||
// For now, default to false in non-interactive contexts
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior based on permission mode
|
||||
switch (settings.permissionMode) {
|
||||
case 'acceptEdits':
|
||||
// Accept file edits but ask for other tools
|
||||
return toolName.toLowerCase() == 'fileedit' ||
|
||||
toolName.toLowerCase() == 'filewrite';
|
||||
case 'dontAsk':
|
||||
// Don't ask, just run (legacy default)
|
||||
return true;
|
||||
case 'plan':
|
||||
// Only allow during plan execution
|
||||
return true; // Would check plan context in real implementation
|
||||
case 'bubble':
|
||||
// Bubble up to parent process for decision
|
||||
return false; // Default deny, parent must approve
|
||||
case 'auto':
|
||||
// Auto-detect based on tool safety
|
||||
return _isToolConsideredSafe(toolName);
|
||||
case 'default':
|
||||
default:
|
||||
// Default behavior: allow safe tools, ask for others
|
||||
return _isToolConsideredSafe(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a tool should trigger a user confirmation.
|
||||
/// Pass the full normalized input and the working directory so path-based
|
||||
/// tools (Read, Glob, Grep) can auto-allow paths inside the cwd.
|
||||
bool shouldAskForPermission(
|
||||
String toolName,
|
||||
Map<String, dynamic> input,
|
||||
String? workingDirectory,
|
||||
) {
|
||||
// alwaysAsk rules always win
|
||||
for (final rule in settings.alwaysAskRules) {
|
||||
if (_matchesRule(toolName, null, rule)) return true;
|
||||
}
|
||||
|
||||
// explicit deny → dont ask (tool will be denied, not prompted)
|
||||
if (settings.alwaysDenyRules.any((r) => _matchesRule(toolName, null, r))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// explicit allow → no prompt needed
|
||||
if (settings.alwaysAllowRules.any((r) => _matchesRule(toolName, null, r))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (settings.permissionMode) {
|
||||
case 'bypassPermissions':
|
||||
return false;
|
||||
case 'dontAsk':
|
||||
return false;
|
||||
case 'bubble':
|
||||
return true;
|
||||
case 'acceptEdits':
|
||||
// edit/write tools are auto-accepted, everything else prompts
|
||||
final n = toolName.toLowerCase();
|
||||
return !(n == 'edit' || n == 'write' || n == 'fileedit' || n == 'filewrite');
|
||||
case 'default':
|
||||
case 'auto':
|
||||
default:
|
||||
return _defaultShouldAsk(toolName, input, workingDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
bool _defaultShouldAsk(
|
||||
String toolName,
|
||||
Map<String, dynamic> input,
|
||||
String? workingDirectory,
|
||||
) {
|
||||
final cwd = workingDirectory?.trim();
|
||||
|
||||
switch (toolName) {
|
||||
// read-only filesystem tools: allow if path is inside cwd
|
||||
case 'Read':
|
||||
case 'Glob':
|
||||
case 'Grep':
|
||||
if (cwd == null || cwd.isEmpty) return true;
|
||||
final pathArg = (input['file_path'] ?? input['path']) as String?;
|
||||
if (pathArg == null || pathArg.isEmpty) return false;
|
||||
final abs = p.isAbsolute(pathArg) ? pathArg : p.join(cwd, pathArg);
|
||||
final norm = p.normalize(abs);
|
||||
return !norm.startsWith(p.normalize(cwd));
|
||||
|
||||
// write tools always ask
|
||||
case 'Edit':
|
||||
case 'Write':
|
||||
case 'Bash':
|
||||
return true;
|
||||
|
||||
// network tools always ask
|
||||
case 'WebSearch':
|
||||
case 'WebFetch':
|
||||
return true;
|
||||
|
||||
// everything else asks by default
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get permission decision for a tool (allowed, denied, or ask)
|
||||
String getPermissionDecision(String toolName, Map<String, dynamic> input, String? workingDirectory) {
|
||||
if (!isToolAllowed(toolName)) {
|
||||
return 'denied';
|
||||
}
|
||||
if (shouldAskForPermission(toolName, input, workingDirectory)) {
|
||||
return 'ask';
|
||||
}
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
/// Parse a permission rule
|
||||
Map<String, dynamic> parsePermissionRule(String rule) {
|
||||
final result = <String, dynamic>{
|
||||
'tool': '',
|
||||
'pattern': '',
|
||||
'args': '',
|
||||
};
|
||||
|
||||
// Check for tool(args) pattern
|
||||
final match = RegExp(r'^(\w+)\((.*)\)$').firstMatch(rule);
|
||||
if (match != null) {
|
||||
result['tool'] = match.group(1)?.toLowerCase() ?? '';
|
||||
result['args'] = match.group(2) ?? '';
|
||||
result['pattern'] = rule;
|
||||
} else {
|
||||
// Just tool name
|
||||
result['tool'] = rule.toLowerCase();
|
||||
result['pattern'] = rule;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Check if a tool matches a permission rule
|
||||
bool _matchesRule(String toolName, String? toolArgs, String rule) {
|
||||
final parsed = parsePermissionRule(rule);
|
||||
final ruleTool = parsed['tool'] as String;
|
||||
|
||||
// Check tool name match
|
||||
if (toolName.toLowerCase() != ruleTool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check args if specified in rule
|
||||
final ruleArgs = parsed['args'] as String;
|
||||
if (ruleArgs.isNotEmpty && toolArgs != null) {
|
||||
// Simple substring matching for args
|
||||
return toolArgs.contains(ruleArgs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Determine if a tool is considered "safe" for auto-allow
|
||||
bool _isToolConsideredSafe(String toolName) {
|
||||
const safeTools = {
|
||||
'bash': false, // Can run arbitrary commands
|
||||
'fileedit': false, // Modifies files
|
||||
'filewrite': false, // Writes files
|
||||
'websearch': false, // Makes network requests
|
||||
'webfetch': false, // Makes network requests
|
||||
'agent': false, // Can spawn other agents
|
||||
'task': false, // Can run background tasks
|
||||
'skill': false, // Can execute arbitrary skills
|
||||
'mcp': false, // Can connect to external servers
|
||||
'glob': true, // Just lists files
|
||||
'grep': true, // Just searches files
|
||||
'fileread': true, // Just reads files
|
||||
'simpleagent': false, // Agent operations
|
||||
};
|
||||
|
||||
return safeTools[toolName.toLowerCase()] ?? false;
|
||||
}
|
||||
|
||||
/// Get user confirmation for a tool (simulated for now)
|
||||
Future<bool> getUserConfirmation(
|
||||
String toolName, String? toolArgs, String prompt) async {
|
||||
// In a real implementation, this would show an interactive prompt
|
||||
// For now, simulate based on environment variable
|
||||
final autoConfirm = Platform.environment['CLAWED_AUTO_CONFIRM'] == 'true';
|
||||
|
||||
if (autoConfirm) {
|
||||
print('Auto-confirming: $toolName $toolArgs');
|
||||
return true;
|
||||
}
|
||||
|
||||
print('Permission required: $prompt');
|
||||
print('Tool: $toolName${toolArgs != null ? ' with args: $toolArgs' : ''}');
|
||||
print('Run with CLAWED_AUTO_CONFIRM=true to auto-confirm.');
|
||||
|
||||
// Default deny in non-interactive mode
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Format permission rules for display
|
||||
String formatPermissionRules() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Permission Mode: ${settings.permissionMode}');
|
||||
buffer.writeln();
|
||||
|
||||
if (settings.alwaysAllowRules.isNotEmpty) {
|
||||
buffer.writeln('Always Allow:');
|
||||
for (final rule in settings.alwaysAllowRules) {
|
||||
buffer.writeln(' • $rule');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
if (settings.alwaysDenyRules.isNotEmpty) {
|
||||
buffer.writeln('Always Deny:');
|
||||
for (final rule in settings.alwaysDenyRules) {
|
||||
buffer.writeln(' • $rule');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
if (settings.alwaysAskRules.isNotEmpty) {
|
||||
buffer.writeln('Always Ask:');
|
||||
for (final rule in settings.alwaysAskRules) {
|
||||
buffer.writeln(' • $rule');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
if (settings.alwaysAllowRules.isEmpty &&
|
||||
settings.alwaysDenyRules.isEmpty &&
|
||||
settings.alwaysAskRules.isEmpty) {
|
||||
buffer.writeln('No specific permission rules configured.');
|
||||
buffer.writeln('Using mode: ${settings.permissionMode}');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import "dart:async";
|
||||
|
||||
enum PermissionDecision { allowOnce, allowAlways, reject }
|
||||
|
||||
class PendingPermission {
|
||||
PendingPermission({required this.toolName, required this.input})
|
||||
: _completer = Completer<PermissionDecision>();
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic> input;
|
||||
final Completer<PermissionDecision> _completer;
|
||||
|
||||
Future<PermissionDecision> get future => _completer.future;
|
||||
|
||||
void resolve(PermissionDecision decision) {
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.complete(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:path/path.dart" as p;
|
||||
|
||||
import "local_state.dart";
|
||||
import "session/session_store.dart";
|
||||
|
||||
const _encoder = JsonEncoder.withIndent(" ");
|
||||
|
||||
String getProjectSettingsPath(String workingDirectory) {
|
||||
return p.join(getProjectAgencyDir(workingDirectory), "settings.json");
|
||||
}
|
||||
|
||||
class ProjectSettingsStore {
|
||||
ProjectSettingsStore._();
|
||||
|
||||
static final ProjectSettingsStore instance = ProjectSettingsStore._();
|
||||
|
||||
Future<LocalSettings?> load(String workingDirectory) async {
|
||||
final file = File(getProjectSettingsPath(workingDirectory));
|
||||
if (!await file.exists()) return null;
|
||||
|
||||
try {
|
||||
final raw = await file.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return LocalSettings.fromJson(decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> save(String workingDirectory, LocalSettings settings) async {
|
||||
final dir = Directory(getProjectAgencyDir(workingDirectory));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final file = File(getProjectSettingsPath(workingDirectory));
|
||||
await file.writeAsString("${_encoder.convert(settings.toJson())}\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../constants.dart';
|
||||
|
||||
/// Analytics and telemetry service
|
||||
/// Supports both local logging and remote analytics with vendor-neutral abstraction
|
||||
class AnalyticsService {
|
||||
static final AnalyticsService _instance = AnalyticsService._internal();
|
||||
factory AnalyticsService() => _instance;
|
||||
AnalyticsService._internal();
|
||||
|
||||
bool _enabled = true;
|
||||
bool _initialized = false;
|
||||
final List<Map<String, dynamic>> _eventBuffer = [];
|
||||
final String _logPath = _getLogFilePath();
|
||||
|
||||
/// Initialize analytics service
|
||||
Future<void> initialize({bool enabled = true}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
_enabled = enabled && areRemoteServicesAvailable();
|
||||
_initialized = true;
|
||||
|
||||
// Load existing log if any
|
||||
await _loadEventBuffer();
|
||||
|
||||
// Log initialization event
|
||||
await _logEvent('analytics_initialized', {
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
'enabled': _enabled,
|
||||
'remote_services_available': areRemoteServicesAvailable(),
|
||||
});
|
||||
|
||||
// Flush buffer if we have pending events
|
||||
if (_eventBuffer.isNotEmpty) {
|
||||
await _flushEvents();
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an event
|
||||
Future<void> logEvent(String eventName,
|
||||
{Map<String, dynamic>? properties,
|
||||
Map<String, dynamic>? metrics}) async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
if (!_enabled) return;
|
||||
|
||||
final event = <String, dynamic>{
|
||||
'event': eventName,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
if (properties != null && properties.isNotEmpty) 'properties': properties,
|
||||
if (metrics != null && metrics.isNotEmpty) 'metrics': metrics,
|
||||
};
|
||||
|
||||
// Add to buffer and try to send
|
||||
_eventBuffer.add(event);
|
||||
await _saveEventBuffer();
|
||||
|
||||
// Try to send immediately, but don't block
|
||||
_flushEventsInBackground();
|
||||
}
|
||||
|
||||
/// Log command execution
|
||||
Future<void> logCommand(String commandName,
|
||||
{List<String>? args,
|
||||
int? exitCode,
|
||||
int? durationMs,
|
||||
String? sessionId}) async {
|
||||
await logEvent('command_executed', properties: {
|
||||
'command': commandName,
|
||||
if (args != null && args.isNotEmpty) 'args_count': args.length,
|
||||
if (exitCode != null) 'exit_code': exitCode,
|
||||
if (durationMs != null) 'duration_ms': durationMs,
|
||||
if (sessionId != null) 'session_id': sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Log tool execution
|
||||
Future<void> logTool(String toolName,
|
||||
{Map<String, dynamic>? input,
|
||||
String? result,
|
||||
int? durationMs,
|
||||
bool? allowed}) async {
|
||||
await logEvent('tool_executed', properties: {
|
||||
'tool': toolName,
|
||||
if (input != null && input.isNotEmpty) 'input_keys': input.keys.toList(),
|
||||
if (result != null) 'result_length': result.length,
|
||||
if (durationMs != null) 'duration_ms': durationMs,
|
||||
if (allowed != null) 'allowed': allowed,
|
||||
});
|
||||
}
|
||||
|
||||
/// Log model usage
|
||||
Future<void> logModelUsage(String modelName,
|
||||
{int? inputTokens,
|
||||
int? outputTokens,
|
||||
double? costUsd,
|
||||
int? durationMs}) async {
|
||||
await logEvent('model_usage', properties: {
|
||||
'model': modelName,
|
||||
if (inputTokens != null) 'input_tokens': inputTokens,
|
||||
if (outputTokens != null) 'output_tokens': outputTokens,
|
||||
if (costUsd != null) 'cost_usd': costUsd,
|
||||
if (durationMs != null) 'duration_ms': durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/// Log error
|
||||
Future<void> logError(String errorType,
|
||||
{String? message,
|
||||
StackTrace? stackTrace,
|
||||
String? context}) async {
|
||||
await logEvent('error', properties: {
|
||||
'type': errorType,
|
||||
if (message != null) 'message': message,
|
||||
if (context != null) 'context': context,
|
||||
if (stackTrace != null) 'stack_trace': stackTrace.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Log session start/end
|
||||
Future<void> logSession(String sessionId, String action,
|
||||
{int? messageCount,
|
||||
int? durationMs,
|
||||
double? totalCost}) async {
|
||||
await logEvent('session_$action', properties: {
|
||||
'session_id': sessionId,
|
||||
if (messageCount != null) 'message_count': messageCount,
|
||||
if (durationMs != null) 'duration_ms': durationMs,
|
||||
if (totalCost != null) 'total_cost': totalCost,
|
||||
});
|
||||
}
|
||||
|
||||
/// Flush events to remote service
|
||||
Future<void> flush() async {
|
||||
await _flushEvents();
|
||||
}
|
||||
|
||||
/// Get analytics status
|
||||
Map<String, dynamic> getStatus() {
|
||||
return {
|
||||
'enabled': _enabled,
|
||||
'initialized': _initialized,
|
||||
'buffer_size': _eventBuffer.length,
|
||||
'log_path': _logPath,
|
||||
'remote_services_available': areRemoteServicesAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Enable or disable analytics
|
||||
void setEnabled(bool enabled) {
|
||||
_enabled = enabled;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
Future<void> _flushEvents() async {
|
||||
if (_eventBuffer.isEmpty) return;
|
||||
|
||||
final eventsToSend = List<Map<String, dynamic>>.from(_eventBuffer);
|
||||
_eventBuffer.clear();
|
||||
|
||||
// Try to send to remote service
|
||||
bool remoteSuccess = false;
|
||||
if (shouldUseRemoteService('analytics')) {
|
||||
try {
|
||||
remoteSuccess = await _sendToRemoteService(eventsToSend);
|
||||
} catch (e) {
|
||||
// Log error but continue with local fallback
|
||||
await _logLocal('Failed to send analytics to remote: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Always log locally as backup
|
||||
await _logLocal('Analytics events (remote: $remoteSuccess):');
|
||||
for (final event in eventsToSend) {
|
||||
await _logLocal(' ${jsonEncode(event)}');
|
||||
}
|
||||
|
||||
await _saveEventBuffer();
|
||||
}
|
||||
|
||||
void _flushEventsInBackground() {
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
await _flushEvents();
|
||||
} catch (e) {
|
||||
// Silently fail for background flushes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _sendToRemoteService(List<Map<String, dynamic>> events) async {
|
||||
if (!shouldUseRemoteService('analytics')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final url = getRemoteServiceUrl('${ApiPaths.analytics}/batch');
|
||||
final client = HttpClient();
|
||||
final request = await client.postUrl(Uri.parse(url));
|
||||
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.write(jsonEncode({
|
||||
'events': events,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
'client': 'clawd_code',
|
||||
'version': '1.0.0',
|
||||
}));
|
||||
|
||||
final response = await request.close();
|
||||
await response.drain();
|
||||
|
||||
return response.statusCode >= 200 && response.statusCode < 300;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logEvent(String eventName, Map<String, dynamic> data) async {
|
||||
final logFile = File(_logPath);
|
||||
final logLine = jsonEncode({
|
||||
'event': eventName,
|
||||
...data,
|
||||
'logged_at': DateTime.now().toUtc().toIso8601String(),
|
||||
});
|
||||
|
||||
try {
|
||||
await logFile.parent.create(recursive: true);
|
||||
await logFile.writeAsString('$logLine\n', mode: FileMode.append);
|
||||
} catch (e) {
|
||||
// Silently fail if we can't write to log
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logLocal(String message) async {
|
||||
final logFile = File(_logPath);
|
||||
final logLine =
|
||||
'[${DateTime.now().toLocal()}] $message\n';
|
||||
|
||||
try {
|
||||
await logFile.parent.create(recursive: true);
|
||||
await logFile.writeAsString(logLine, mode: FileMode.append);
|
||||
} catch (e) {
|
||||
// Silently fail if we can't write to log
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadEventBuffer() async {
|
||||
final logFile = File(_logPath);
|
||||
if (!await logFile.exists()) return;
|
||||
|
||||
try {
|
||||
final lines = await logFile.readAsLines();
|
||||
for (final line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final event = jsonDecode(line) as Map<String, dynamic>;
|
||||
_eventBuffer.add(event);
|
||||
} catch (e) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Reset buffer if we can't read the file
|
||||
_eventBuffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEventBuffer() async {
|
||||
final logFile = File(_logPath);
|
||||
try {
|
||||
await logFile.parent.create(recursive: true);
|
||||
final lines = _eventBuffer.map(jsonEncode).join('\n');
|
||||
await logFile.writeAsString('$lines\n');
|
||||
} catch (e) {
|
||||
// Silently fail if we can't write to log
|
||||
}
|
||||
}
|
||||
|
||||
static String _getLogFilePath() {
|
||||
final home = Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'] ??
|
||||
Directory.current.path;
|
||||
return Platform.isWindows
|
||||
? '$home\\.clawd_code\\analytics.log'
|
||||
: '$home/.clawd_code/analytics.log';
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
// Anthropic API client stub
|
||||
// Ported from old_repo/services/api/client.ts
|
||||
// Full implementation requires HTTP + auth — stubbed with TODOs
|
||||
// Vendor-neutral API client stub
|
||||
// Generic client that can work with multiple providers
|
||||
|
||||
import "dart:io";
|
||||
|
||||
enum ApiProvider { anthropic, bedrock, vertex, foundry }
|
||||
enum ApiProvider { generic, anthropic, openrouter, bedrock, vertex, foundry }
|
||||
|
||||
ApiProvider getApiProvider() {
|
||||
final env = Platform.environment;
|
||||
|
||||
// Check for vendor-specific flags
|
||||
if (_isTruthy(env["CLAUDE_CODE_USE_BEDROCK"])) return ApiProvider.bedrock;
|
||||
if (_isTruthy(env["CLAUDE_CODE_USE_VERTEX"])) return ApiProvider.vertex;
|
||||
if (_isTruthy(env["CLAUDE_CODE_USE_FOUNDRY"])) return ApiProvider.foundry;
|
||||
if (_isTruthy(env["USE_OPENROUTER"])) return ApiProvider.openrouter;
|
||||
if (_isTruthy(env["USE_ANTHROPIC"])) return ApiProvider.anthropic;
|
||||
|
||||
return ApiProvider.anthropic;
|
||||
// Default to generic
|
||||
return ApiProvider.generic;
|
||||
}
|
||||
|
||||
bool _isTruthy(String? v) {
|
||||
@@ -62,7 +65,19 @@ String? resolveApiKey() {
|
||||
|
||||
String resolveBaseUrl() {
|
||||
final env = Platform.environment;
|
||||
final override = env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
|
||||
final override = env["ANTHROPIC_BASE_URL"] ??
|
||||
env["CLAUDE_CODE_BASE_URL"] ??
|
||||
env["OPENROUTER_BASE_URL"] ??
|
||||
env["API_BASE_URL"];
|
||||
if (override != null && override.isNotEmpty) return override;
|
||||
return "https://api.anthropic.com";
|
||||
|
||||
// No vendor-specific defaults — require explicit configuration
|
||||
throw StateError(
|
||||
'Base URL not configured. Set one of:\n'
|
||||
' ANTHROPIC_BASE_URL (for Anthropic)\n'
|
||||
' CLAUDE_CODE_BASE_URL (for Claude Code backend)\n'
|
||||
' OPENROUTER_BASE_URL (for OpenRouter)\n'
|
||||
' API_BASE_URL (generic fallback)\n'
|
||||
'Or use vendor-neutral kHostEndpoint from lib/src/constants.dart'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ class ModelUsage {
|
||||
int cacheReadInputTokens;
|
||||
int cacheCreationInputTokens;
|
||||
int webSearchRequests;
|
||||
int webFetchRequests;
|
||||
double costUsd;
|
||||
int contextWindow;
|
||||
int maxOutputTokens;
|
||||
@@ -18,6 +19,7 @@ class ModelUsage {
|
||||
this.cacheReadInputTokens = 0,
|
||||
this.cacheCreationInputTokens = 0,
|
||||
this.webSearchRequests = 0,
|
||||
this.webFetchRequests = 0,
|
||||
this.costUsd = 0.0,
|
||||
this.contextWindow = 0,
|
||||
this.maxOutputTokens = 0,
|
||||
@@ -29,6 +31,7 @@ class ModelUsage {
|
||||
"cacheReadInputTokens": cacheReadInputTokens,
|
||||
"cacheCreationInputTokens": cacheCreationInputTokens,
|
||||
"webSearchRequests": webSearchRequests,
|
||||
"webFetchRequests": webFetchRequests,
|
||||
"costUsd": costUsd,
|
||||
};
|
||||
|
||||
@@ -38,6 +41,7 @@ class ModelUsage {
|
||||
cacheReadInputTokens: (json["cacheReadInputTokens"] as num?)?.toInt() ?? 0,
|
||||
cacheCreationInputTokens: (json["cacheCreationInputTokens"] as num?)?.toInt() ?? 0,
|
||||
webSearchRequests: (json["webSearchRequests"] as num?)?.toInt() ?? 0,
|
||||
webFetchRequests: (json["webFetchRequests"] as num?)?.toInt() ?? 0,
|
||||
costUsd: (json["costUsd"] as num?)?.toDouble() ?? 0.0,
|
||||
);
|
||||
}
|
||||
@@ -50,6 +54,7 @@ class _CostState {
|
||||
int totalCacheReadInputTokens = 0;
|
||||
int totalCacheCreationInputTokens = 0;
|
||||
int totalWebSearchRequests = 0;
|
||||
int totalWebFetchRequests = 0;
|
||||
int totalApiDurationMs = 0;
|
||||
int totalApiDurationWithoutRetriesMs = 0;
|
||||
int totalToolDurationMs = 0;
|
||||
@@ -69,6 +74,7 @@ int getTotalOutputTokens() => _state.totalOutputTokens;
|
||||
int getTotalCacheReadInputTokens() => _state.totalCacheReadInputTokens;
|
||||
int getTotalCacheCreationInputTokens() => _state.totalCacheCreationInputTokens;
|
||||
int getTotalWebSearchRequests() => _state.totalWebSearchRequests;
|
||||
int getTotalWebFetchRequests() => _state.totalWebFetchRequests;
|
||||
int getTotalApiDurationMs() => _state.totalApiDurationMs;
|
||||
int getTotalApiDurationWithoutRetriesMs() => _state.totalApiDurationWithoutRetriesMs;
|
||||
int getTotalToolDurationMs() => _state.totalToolDurationMs;
|
||||
@@ -98,6 +104,7 @@ void resetCostState() {
|
||||
_state.totalCacheReadInputTokens = 0;
|
||||
_state.totalCacheCreationInputTokens = 0;
|
||||
_state.totalWebSearchRequests = 0;
|
||||
_state.totalWebFetchRequests = 0;
|
||||
_state.totalApiDurationMs = 0;
|
||||
_state.totalApiDurationWithoutRetriesMs = 0;
|
||||
_state.totalToolDurationMs = 0;
|
||||
@@ -142,6 +149,7 @@ double addToTotalSessionCost({
|
||||
required int cacheReadTokens,
|
||||
required int cacheCreationTokens,
|
||||
int webSearchRequests = 0,
|
||||
int webFetchRequests = 0,
|
||||
required String model,
|
||||
}) {
|
||||
_state.totalCostUsd += cost;
|
||||
@@ -150,6 +158,7 @@ double addToTotalSessionCost({
|
||||
_state.totalCacheReadInputTokens += cacheReadTokens;
|
||||
_state.totalCacheCreationInputTokens += cacheCreationTokens;
|
||||
_state.totalWebSearchRequests += webSearchRequests;
|
||||
_state.totalWebFetchRequests += webFetchRequests;
|
||||
|
||||
final existing = _state.modelUsage.putIfAbsent(model, ModelUsage.new);
|
||||
existing.inputTokens += inputTokens;
|
||||
@@ -157,6 +166,7 @@ double addToTotalSessionCost({
|
||||
existing.cacheReadInputTokens += cacheReadTokens;
|
||||
existing.cacheCreationInputTokens += cacheCreationTokens;
|
||||
existing.webSearchRequests += webSearchRequests;
|
||||
existing.webFetchRequests += webFetchRequests;
|
||||
existing.costUsd += cost;
|
||||
|
||||
return _state.totalCostUsd;
|
||||
@@ -209,6 +219,7 @@ String formatTotalCost() {
|
||||
"${_fmt(u.cacheReadInputTokens)} cache read, "
|
||||
"${_fmt(u.cacheCreationInputTokens)} cache write"
|
||||
"${u.webSearchRequests > 0 ? ", ${_fmt(u.webSearchRequests)} web search" : ""}"
|
||||
"${u.webFetchRequests > 0 ? ", ${_fmt(u.webFetchRequests)} web fetch" : ""}"
|
||||
" (${formatCost(u.costUsd)})";
|
||||
final label = "${entry.key}:".padLeft(21);
|
||||
buf.write("\n$label$line");
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Process management service for task execution
|
||||
// Handles spawning, monitoring, and terminating sub-processes
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../tools/task_tool.dart';
|
||||
|
||||
typedef ProcessCallback = void Function(String output);
|
||||
|
||||
/// Represents a running process
|
||||
class ManagedProcess {
|
||||
final String id;
|
||||
final String taskId;
|
||||
final String command;
|
||||
final List<String> arguments;
|
||||
final String workingDirectory;
|
||||
|
||||
late Process _process;
|
||||
late StreamSubscription<String> _stdoutSub;
|
||||
late StreamSubscription<String> _stderrSub;
|
||||
|
||||
final List<String> _output = [];
|
||||
final List<String> _errors = [];
|
||||
|
||||
DateTime _startTime = DateTime.now();
|
||||
DateTime? _endTime;
|
||||
|
||||
int? _exitCode;
|
||||
bool _isRunning = true;
|
||||
|
||||
ManagedProcess({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.command,
|
||||
required this.arguments,
|
||||
required this.workingDirectory,
|
||||
});
|
||||
|
||||
/// Start the process
|
||||
Future<void> start() async {
|
||||
try {
|
||||
_process = await Process.start(
|
||||
command,
|
||||
arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
includeParentEnvironment: true,
|
||||
);
|
||||
|
||||
// Listen to stdout
|
||||
_stdoutSub = _process.stdout
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((line) {
|
||||
_output.add(line);
|
||||
});
|
||||
|
||||
// Listen to stderr
|
||||
_stderrSub = _process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((line) {
|
||||
_errors.add(line);
|
||||
});
|
||||
|
||||
// Wait for process to exit
|
||||
_exitCode = await _process.exitCode;
|
||||
_isRunning = false;
|
||||
_endTime = DateTime.now();
|
||||
|
||||
// Cancel subscriptions
|
||||
await _stdoutSub.cancel();
|
||||
await _stderrSub.cancel();
|
||||
} catch (e) {
|
||||
_isRunning = false;
|
||||
_endTime = DateTime.now();
|
||||
_errors.add('Failed to start process: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate the process
|
||||
Future<bool> terminate({bool force = false}) async {
|
||||
if (!_isRunning) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (force) {
|
||||
// Force kill
|
||||
return _process.kill(ProcessSignal.sigkill);
|
||||
} else {
|
||||
// Graceful shutdown
|
||||
_process.kill(ProcessSignal.sigterm);
|
||||
|
||||
// Wait up to 5 seconds for graceful shutdown
|
||||
for (int i = 0; i < 50; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!_isRunning) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If still running, force kill
|
||||
return _process.kill(ProcessSignal.sigkill);
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current output
|
||||
String getOutput() => _output.join('\n');
|
||||
|
||||
/// Get current errors
|
||||
String getErrors() => _errors.join('\n');
|
||||
|
||||
/// Get full output (both stdout and stderr)
|
||||
String getFullOutput() {
|
||||
final combined = <String>[];
|
||||
combined.addAll(_output);
|
||||
if (_errors.isNotEmpty) {
|
||||
combined.add('\n--- STDERR ---');
|
||||
combined.addAll(_errors);
|
||||
}
|
||||
return combined.join('\n');
|
||||
}
|
||||
|
||||
/// Public getters for process state
|
||||
bool get isRunning => _isRunning;
|
||||
int? get exitCode => _exitCode;
|
||||
DateTime get startTime => _startTime;
|
||||
DateTime? get endTime => _endTime;
|
||||
|
||||
/// Get process info
|
||||
Map<String, dynamic> toJson() {
|
||||
final duration = (_endTime ?? DateTime.now()).difference(_startTime);
|
||||
return {
|
||||
'id': id,
|
||||
'task_id': taskId,
|
||||
'command': command,
|
||||
'arguments': arguments,
|
||||
'working_directory': workingDirectory,
|
||||
'is_running': _isRunning,
|
||||
'exit_code': _exitCode,
|
||||
'duration_ms': duration.inMilliseconds,
|
||||
'output_lines': _output.length,
|
||||
'error_lines': _errors.length,
|
||||
'started_at': _startTime.toIso8601String(),
|
||||
'ended_at': _endTime?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages multiple processes
|
||||
class ProcessManager {
|
||||
static final ProcessManager _instance = ProcessManager._internal();
|
||||
factory ProcessManager() => _instance;
|
||||
ProcessManager._internal();
|
||||
|
||||
final Map<String, ManagedProcess> _processes = {};
|
||||
int _idCounter = 1;
|
||||
|
||||
/// Spawn a new process
|
||||
Future<ManagedProcess> spawn({
|
||||
required String taskId,
|
||||
required String command,
|
||||
required List<String> arguments,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
final id = 'proc_${_idCounter++}';
|
||||
|
||||
final process = ManagedProcess(
|
||||
id: id,
|
||||
taskId: taskId,
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
|
||||
_processes[id] = process;
|
||||
|
||||
// Start in background
|
||||
process.start().then((_) {
|
||||
// Process completed
|
||||
}).catchError((e) {
|
||||
print('Process $id failed: $e');
|
||||
});
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
/// Get a process by ID
|
||||
ManagedProcess? getProcess(String id) => _processes[id];
|
||||
|
||||
/// Get all processes for a task
|
||||
List<ManagedProcess> getTaskProcesses(String taskId) {
|
||||
return _processes.values
|
||||
.where((p) => p.taskId == taskId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Terminate a process
|
||||
Future<bool> terminateProcess(String id, {bool force = false}) async {
|
||||
final process = _processes[id];
|
||||
if (process == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await process.terminate(force: force);
|
||||
if (result) {
|
||||
_processes.remove(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Terminate all processes for a task
|
||||
Future<void> terminateTaskProcesses(String taskId, {bool force = false}) async {
|
||||
final processes = getTaskProcesses(taskId);
|
||||
for (final process in processes) {
|
||||
await process.terminate(force: force);
|
||||
_processes.remove(process.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all processes
|
||||
List<ManagedProcess> getAllProcesses() => _processes.values.toList();
|
||||
|
||||
/// Get process count
|
||||
int getProcessCount() => _processes.length;
|
||||
|
||||
/// Get running process count
|
||||
int getRunningProcessCount() =>
|
||||
_processes.values.where((p) => p.isRunning).length;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Task execution service
|
||||
// Bridges task definitions to actual process execution
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'process_manager.dart';
|
||||
|
||||
/// Status of a task execution
|
||||
enum TaskExecutionStatus {
|
||||
created,
|
||||
queued,
|
||||
running,
|
||||
completed,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
/// Result of a task execution
|
||||
class TaskExecutionResult {
|
||||
final String taskId;
|
||||
final String processId;
|
||||
final TaskExecutionStatus status;
|
||||
final int? exitCode;
|
||||
final String output;
|
||||
final String errors;
|
||||
final Duration? duration;
|
||||
|
||||
const TaskExecutionResult({
|
||||
required this.taskId,
|
||||
required this.processId,
|
||||
required this.status,
|
||||
this.exitCode,
|
||||
required this.output,
|
||||
required this.errors,
|
||||
this.duration,
|
||||
});
|
||||
|
||||
bool get isSuccess => exitCode == 0;
|
||||
bool get isRunning => status == TaskExecutionStatus.running;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'task_id': taskId,
|
||||
'process_id': processId,
|
||||
'status': status.name,
|
||||
'exit_code': exitCode,
|
||||
'output': output,
|
||||
'errors': errors,
|
||||
'duration_ms': duration?.inMilliseconds,
|
||||
'is_success': isSuccess,
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes tasks as background processes
|
||||
class TaskExecutor {
|
||||
static final TaskExecutor _instance = TaskExecutor._internal();
|
||||
factory TaskExecutor() => _instance;
|
||||
TaskExecutor._internal() : _processManager = ProcessManager();
|
||||
|
||||
final ProcessManager _processManager;
|
||||
final Map<String, TaskExecutionResult> _results = {};
|
||||
|
||||
/// Execute a task command
|
||||
/// Returns immediately with process ID
|
||||
/// Call getResult() to check completion
|
||||
Future<String> executeTask({
|
||||
required String taskId,
|
||||
required String command,
|
||||
required List<String> arguments,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
try {
|
||||
final process = await _processManager.spawn(
|
||||
taskId: taskId,
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
|
||||
return process.id;
|
||||
} catch (e) {
|
||||
throw TaskExecutionException(
|
||||
'Failed to execute task: $e',
|
||||
taskId: taskId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get execution result (non-blocking)
|
||||
/// Returns null if process still running
|
||||
/// Returns result when complete
|
||||
TaskExecutionResult? getResult(String processId) {
|
||||
final process = _processManager.getProcess(processId);
|
||||
if (process == null) {
|
||||
return _results[processId];
|
||||
}
|
||||
|
||||
// Process still exists, check if running
|
||||
if (process.isRunning) {
|
||||
return null; // Still running
|
||||
}
|
||||
|
||||
// Process completed, return result
|
||||
final result = TaskExecutionResult(
|
||||
taskId: process.taskId,
|
||||
processId: processId,
|
||||
status: process.exitCode == 0
|
||||
? TaskExecutionStatus.completed
|
||||
: TaskExecutionStatus.failed,
|
||||
exitCode: process.exitCode,
|
||||
output: process.getOutput(),
|
||||
errors: process.getErrors(),
|
||||
duration: process.endTime?.difference(process.startTime),
|
||||
);
|
||||
|
||||
_results[processId] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Wait for process completion
|
||||
/// Polls for result (blocking)
|
||||
Future<TaskExecutionResult> waitForResult(
|
||||
String processId, {
|
||||
Duration timeout = const Duration(hours: 24),
|
||||
}) async {
|
||||
final endTime = DateTime.now().add(timeout);
|
||||
|
||||
while (DateTime.now().isBefore(endTime)) {
|
||||
final result = getResult(processId);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Wait 100ms before checking again
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
throw TaskExecutionException(
|
||||
'Task execution timeout after ${timeout.inSeconds} seconds',
|
||||
taskId: '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Watch process output in real-time
|
||||
/// Returns stream of output lines
|
||||
Stream<String> watchOutput(String processId) async* {
|
||||
final process = _processManager.getProcess(processId);
|
||||
if (process == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial output
|
||||
final output = process.getOutput();
|
||||
if (output.isNotEmpty) {
|
||||
for (final line in output.split('\n')) {
|
||||
yield line;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for more output (simplified - real version would stream)
|
||||
while (process.isRunning) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final newOutput = process.getOutput();
|
||||
if (newOutput.isNotEmpty) {
|
||||
yield newOutput;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate a task
|
||||
Future<bool> cancelTask(String processId, {bool force = false}) async {
|
||||
return _processManager.terminateProcess(processId, force: force);
|
||||
}
|
||||
|
||||
/// Get all active tasks
|
||||
List<Map<String, dynamic>> getActiveTasks() {
|
||||
return _processManager.getAllProcesses().map((p) => p.toJson()).toList();
|
||||
}
|
||||
|
||||
/// Get task status
|
||||
TaskExecutionStatus getTaskStatus(String processId) {
|
||||
final process = _processManager.getProcess(processId);
|
||||
if (process == null) {
|
||||
final result = _results[processId];
|
||||
return result?.status ?? TaskExecutionStatus.cancelled;
|
||||
}
|
||||
|
||||
if (process.isRunning) {
|
||||
return TaskExecutionStatus.running;
|
||||
}
|
||||
|
||||
return process.exitCode == 0
|
||||
? TaskExecutionStatus.completed
|
||||
: TaskExecutionStatus.failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception thrown during task execution
|
||||
class TaskExecutionException implements Exception {
|
||||
final String message;
|
||||
final String taskId;
|
||||
|
||||
TaskExecutionException(this.message, {required this.taskId});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import "../analytics/analytics_service.dart";
|
||||
import "analytics_config.dart";
|
||||
|
||||
abstract class ToolTelemetryClient {
|
||||
Future<void> recordToolCall({
|
||||
required String toolName,
|
||||
required bool success,
|
||||
int? durationMs,
|
||||
Map<String, Object?> metadata = const <String, Object?>{},
|
||||
});
|
||||
}
|
||||
|
||||
class NullToolTelemetryClient implements ToolTelemetryClient {
|
||||
const NullToolTelemetryClient();
|
||||
|
||||
@override
|
||||
Future<void> recordToolCall({
|
||||
required String toolName,
|
||||
required bool success,
|
||||
int? durationMs,
|
||||
Map<String, Object?> metadata = const <String, Object?>{},
|
||||
}) async {}
|
||||
}
|
||||
|
||||
class LocalToolTelemetryClient implements ToolTelemetryClient {
|
||||
const LocalToolTelemetryClient();
|
||||
|
||||
@override
|
||||
Future<void> recordToolCall({
|
||||
required String toolName,
|
||||
required bool success,
|
||||
int? durationMs,
|
||||
Map<String, Object?> metadata = const <String, Object?>{},
|
||||
}) async {
|
||||
final eventMetadata = <String, Object?>{
|
||||
"tool_name": toolName,
|
||||
"success": success,
|
||||
if (durationMs != null) "duration_ms": durationMs,
|
||||
...metadata,
|
||||
};
|
||||
logAnalyticsEvent("tool_call", eventMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
ToolTelemetryClient createToolTelemetryClient() {
|
||||
if (isTelemetryDisabled()) {
|
||||
return const NullToolTelemetryClient();
|
||||
}
|
||||
return const LocalToolTelemetryClient();
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../constants.dart';
|
||||
|
||||
/// Usage tracking and quota management
|
||||
/// Tracks API usage, tool usage, and enforces limits
|
||||
class UsageTracker {
|
||||
static final UsageTracker _instance = UsageTracker._internal();
|
||||
factory UsageTracker() => _instance;
|
||||
UsageTracker._internal();
|
||||
|
||||
bool _enabled = true;
|
||||
bool _initialized = false;
|
||||
|
||||
// Usage counters
|
||||
final Map<String, dynamic> _usage = {
|
||||
'daily': _resetDailyCounters(),
|
||||
'monthly': _resetMonthlyCounters(),
|
||||
'total': _resetTotalCounters(),
|
||||
};
|
||||
|
||||
// Quota limits (would be loaded from config/remote)
|
||||
final Map<String, dynamic> _limits = {
|
||||
'daily': {
|
||||
'api_calls': 1000,
|
||||
'tokens': 1000000,
|
||||
'tool_executions': 1000,
|
||||
'cost_usd': 10.0,
|
||||
},
|
||||
'monthly': {
|
||||
'api_calls': 30000,
|
||||
'tokens': 30000000,
|
||||
'tool_executions': 30000,
|
||||
'cost_usd': 300.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Last reset times
|
||||
DateTime _lastDailyReset = DateTime.now();
|
||||
DateTime _lastMonthlyReset = DateTime.now();
|
||||
|
||||
/// Initialize usage tracker
|
||||
Future<void> initialize({bool enabled = true}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
_enabled = enabled && areRemoteServicesAvailable();
|
||||
_initialized = true;
|
||||
|
||||
// Load saved usage data
|
||||
await _loadUsageData();
|
||||
|
||||
// Check if we need to reset counters
|
||||
await _checkAndResetCounters();
|
||||
|
||||
// Sync with remote service if available
|
||||
if (shouldUseRemoteService('usage')) {
|
||||
await _syncWithRemote();
|
||||
}
|
||||
}
|
||||
|
||||
/// Track API usage
|
||||
Future<void> trackApiCall(String model,
|
||||
{int? inputTokens,
|
||||
int? outputTokens,
|
||||
double? costUsd,
|
||||
int? durationMs}) async {
|
||||
if (!_enabled) return;
|
||||
|
||||
await _checkAndResetCounters();
|
||||
|
||||
final counters = _usage['daily'] as Map<String, dynamic>;
|
||||
final monthly = _usage['monthly'] as Map<String, dynamic>;
|
||||
final total = _usage['total'] as Map<String, dynamic>;
|
||||
|
||||
// Update counters
|
||||
counters['api_calls'] = (counters['api_calls'] as int) + 1;
|
||||
monthly['api_calls'] = (monthly['api_calls'] as int) + 1;
|
||||
total['api_calls'] = (total['api_calls'] as int) + 1;
|
||||
|
||||
if (inputTokens != null) {
|
||||
counters['tokens'] = (counters['tokens'] as int) + inputTokens;
|
||||
monthly['tokens'] = (monthly['tokens'] as int) + inputTokens;
|
||||
total['tokens'] = (total['tokens'] as int) + inputTokens;
|
||||
}
|
||||
|
||||
if (outputTokens != null) {
|
||||
counters['tokens'] = (counters['tokens'] as int) + outputTokens;
|
||||
monthly['tokens'] = (monthly['tokens'] as int) + outputTokens;
|
||||
total['tokens'] = (total['tokens'] as int) + outputTokens;
|
||||
}
|
||||
|
||||
if (costUsd != null) {
|
||||
counters['cost_usd'] = (counters['cost_usd'] as double) + costUsd;
|
||||
monthly['cost_usd'] = (monthly['cost_usd'] as double) + costUsd;
|
||||
total['cost_usd'] = (total['cost_usd'] as double) + costUsd;
|
||||
}
|
||||
|
||||
// Track per-model usage
|
||||
if (!counters.containsKey('models')) {
|
||||
counters['models'] = {};
|
||||
monthly['models'] = {};
|
||||
total['models'] = {};
|
||||
}
|
||||
|
||||
final modelCounters = counters['models'] as Map<String, dynamic>;
|
||||
final monthlyModels = monthly['models'] as Map<String, dynamic>;
|
||||
final totalModels = total['models'] as Map<String, dynamic>;
|
||||
|
||||
modelCounters[model] = (modelCounters[model] as int? ?? 0) + 1;
|
||||
monthlyModels[model] = (monthlyModels[model] as int? ?? 0) + 1;
|
||||
totalModels[model] = (totalModels[model] as int? ?? 0) + 1;
|
||||
|
||||
await _saveUsageData();
|
||||
await _checkLimits();
|
||||
}
|
||||
|
||||
/// Track tool execution
|
||||
Future<void> trackToolExecution(String toolName,
|
||||
{int? durationMs, Map<String, dynamic>? input}) async {
|
||||
if (!_enabled) return;
|
||||
|
||||
await _checkAndResetCounters();
|
||||
|
||||
final counters = _usage['daily'] as Map<String, dynamic>;
|
||||
final monthly = _usage['monthly'] as Map<String, dynamic>;
|
||||
final total = _usage['total'] as Map<String, dynamic>;
|
||||
|
||||
counters['tool_executions'] = (counters['tool_executions'] as int) + 1;
|
||||
monthly['tool_executions'] = (monthly['tool_executions'] as int) + 1;
|
||||
total['tool_executions'] = (total['tool_executions'] as int) + 1;
|
||||
|
||||
// Track per-tool usage
|
||||
if (!counters.containsKey('tools')) {
|
||||
counters['tools'] = {};
|
||||
monthly['tools'] = {};
|
||||
total['tools'] = {};
|
||||
}
|
||||
|
||||
final toolCounters = counters['tools'] as Map<String, dynamic>;
|
||||
final monthlyTools = monthly['tools'] as Map<String, dynamic>;
|
||||
final totalTools = total['tools'] as Map<String, dynamic>;
|
||||
|
||||
toolCounters[toolName] = (toolCounters[toolName] as int? ?? 0) + 1;
|
||||
monthlyTools[toolName] = (monthlyTools[toolName] as int? ?? 0) + 1;
|
||||
totalTools[toolName] = (totalTools[toolName] as int? ?? 0) + 1;
|
||||
|
||||
await _saveUsageData();
|
||||
}
|
||||
|
||||
/// Check if usage is within limits
|
||||
Future<Map<String, dynamic>> checkLimits() async {
|
||||
await _checkAndResetCounters();
|
||||
|
||||
final daily = _usage['daily'] as Map<String, dynamic>;
|
||||
final monthly = _usage['monthly'] as Map<String, dynamic>;
|
||||
final dailyLimits = _limits['daily'] as Map<String, dynamic>;
|
||||
final monthlyLimits = _limits['monthly'] as Map<String, dynamic>;
|
||||
|
||||
final violations = <String, List<String>>{
|
||||
'daily': [],
|
||||
'monthly': [],
|
||||
};
|
||||
|
||||
// Check daily limits
|
||||
for (final key in dailyLimits.keys) {
|
||||
if (key == 'cost_usd') {
|
||||
final used = daily[key] as double;
|
||||
final limit = dailyLimits[key] as double;
|
||||
if (used > limit) {
|
||||
violations['daily']!.add('$key: $used/$limit');
|
||||
}
|
||||
} else {
|
||||
final used = daily[key] as int;
|
||||
final limit = dailyLimits[key] as int;
|
||||
if (used > limit) {
|
||||
violations['daily']!.add('$key: $used/$limit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check monthly limits
|
||||
for (final key in monthlyLimits.keys) {
|
||||
if (key == 'cost_usd') {
|
||||
final used = monthly[key] as double;
|
||||
final limit = monthlyLimits[key] as double;
|
||||
if (used > limit) {
|
||||
violations['monthly']!.add('$key: $used/$limit');
|
||||
}
|
||||
} else {
|
||||
final used = monthly[key] as int;
|
||||
final limit = monthlyLimits[key] as int;
|
||||
if (used > limit) {
|
||||
violations['monthly']!.add('$key: $used/$limit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'within_limits': violations['daily']!.isEmpty && violations['monthly']!.isEmpty,
|
||||
'violations': violations,
|
||||
'usage': {
|
||||
'daily': daily,
|
||||
'monthly': monthly,
|
||||
'total': _usage['total'],
|
||||
},
|
||||
'limits': _limits,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get usage summary
|
||||
Map<String, dynamic> getUsageSummary() {
|
||||
return {
|
||||
'enabled': _enabled,
|
||||
'daily': Map<String, dynamic>.from(_usage['daily'] as Map),
|
||||
'monthly': Map<String, dynamic>.from(_usage['monthly'] as Map),
|
||||
'total': Map<String, dynamic>.from(_usage['total'] as Map),
|
||||
'limits': Map<String, dynamic>.from(_limits),
|
||||
'last_daily_reset': _lastDailyReset.toIso8601String(),
|
||||
'last_monthly_reset': _lastMonthlyReset.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Reset usage counters
|
||||
Future<void> resetCounters({bool daily = false, bool monthly = false}) async {
|
||||
if (daily) {
|
||||
_usage['daily'] = _resetDailyCounters();
|
||||
_lastDailyReset = DateTime.now();
|
||||
}
|
||||
if (monthly) {
|
||||
_usage['monthly'] = _resetMonthlyCounters();
|
||||
_lastMonthlyReset = DateTime.now();
|
||||
}
|
||||
await _saveUsageData();
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
Future<void> _checkAndResetCounters() async {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Check daily reset (reset at midnight)
|
||||
if (now.day != _lastDailyReset.day ||
|
||||
now.month != _lastDailyReset.month ||
|
||||
now.year != _lastDailyReset.year) {
|
||||
_usage['daily'] = _resetDailyCounters();
|
||||
_lastDailyReset = now;
|
||||
}
|
||||
|
||||
// Check monthly reset (reset on 1st of month)
|
||||
if (now.month != _lastMonthlyReset.month ||
|
||||
now.year != _lastMonthlyReset.year) {
|
||||
_usage['monthly'] = _resetMonthlyCounters();
|
||||
_lastMonthlyReset = now;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkLimits() async {
|
||||
final limits = await checkLimits();
|
||||
if (!limits['within_limits'] as bool) {
|
||||
// Log limit violations
|
||||
final violations = limits['violations'] as Map<String, List<String>>;
|
||||
for (final period in violations.keys) {
|
||||
if (violations[period]!.isNotEmpty) {
|
||||
print('Warning: $period usage limits exceeded: ${violations[period]!.join(', ')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncWithRemote() async {
|
||||
if (!shouldUseRemoteService('usage')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final url = getRemoteServiceUrl('${ApiPaths.usage}/sync');
|
||||
final client = HttpClient();
|
||||
final request = await client.postUrl(Uri.parse(url));
|
||||
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.write(jsonEncode({
|
||||
'usage': _usage,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
}));
|
||||
|
||||
final response = await request.close();
|
||||
if (response.statusCode == 200) {
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
// Update limits from remote
|
||||
if (data.containsKey('limits')) {
|
||||
_limits.clear();
|
||||
_limits.addAll(Map<String, dynamic>.from(data['limits'] as Map));
|
||||
}
|
||||
}
|
||||
|
||||
await response.drain();
|
||||
} catch (e) {
|
||||
// Silently fail - we'll try again later
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadUsageData() async {
|
||||
final usageFile = File(_getUsageFilePath());
|
||||
if (!await usageFile.exists()) return;
|
||||
|
||||
try {
|
||||
final data = jsonDecode(await usageFile.readAsString()) as Map<String, dynamic>;
|
||||
|
||||
if (data.containsKey('usage')) {
|
||||
_usage.clear();
|
||||
_usage.addAll(Map<String, dynamic>.from(data['usage'] as Map));
|
||||
}
|
||||
|
||||
if (data.containsKey('last_daily_reset')) {
|
||||
_lastDailyReset = DateTime.parse(data['last_daily_reset'] as String);
|
||||
}
|
||||
|
||||
if (data.containsKey('last_monthly_reset')) {
|
||||
_lastMonthlyReset = DateTime.parse(data['last_monthly_reset'] as String);
|
||||
}
|
||||
|
||||
if (data.containsKey('limits')) {
|
||||
_limits.clear();
|
||||
_limits.addAll(Map<String, dynamic>.from(data['limits'] as Map));
|
||||
}
|
||||
} catch (e) {
|
||||
// Reset on error
|
||||
_usage['daily'] = _resetDailyCounters();
|
||||
_usage['monthly'] = _resetMonthlyCounters();
|
||||
_usage['total'] = _resetTotalCounters();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveUsageData() async {
|
||||
final usageFile = File(_getUsageFilePath());
|
||||
final data = {
|
||||
'usage': _usage,
|
||||
'last_daily_reset': _lastDailyReset.toIso8601String(),
|
||||
'last_monthly_reset': _lastMonthlyReset.toIso8601String(),
|
||||
'limits': _limits,
|
||||
'saved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
try {
|
||||
await usageFile.parent.create(recursive: true);
|
||||
await usageFile.writeAsString(jsonEncode(data));
|
||||
} catch (e) {
|
||||
// Silently fail if we can't write to file
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _resetDailyCounters() {
|
||||
return {
|
||||
'api_calls': 0,
|
||||
'tokens': 0,
|
||||
'tool_executions': 0,
|
||||
'cost_usd': 0.0,
|
||||
'models': {},
|
||||
'tools': {},
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _resetMonthlyCounters() {
|
||||
return {
|
||||
'api_calls': 0,
|
||||
'tokens': 0,
|
||||
'tool_executions': 0,
|
||||
'cost_usd': 0.0,
|
||||
'models': {},
|
||||
'tools': {},
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _resetTotalCounters() {
|
||||
return {
|
||||
'api_calls': 0,
|
||||
'tokens': 0,
|
||||
'tool_executions': 0,
|
||||
'cost_usd': 0.0,
|
||||
'models': {},
|
||||
'tools': {},
|
||||
};
|
||||
}
|
||||
|
||||
static String _getUsageFilePath() {
|
||||
final home = Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'] ??
|
||||
Directory.current.path;
|
||||
return Platform.isWindows
|
||||
? '$home\\.clawd_code\\usage.json'
|
||||
: '$home/.clawd_code/usage.json';
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ class ConversationHistory {
|
||||
_session = s;
|
||||
}
|
||||
|
||||
void addMessage(String role, String content, {int? tokens}) {
|
||||
void addMessage(String role, String content, {int? tokens, int? contextTokens}) {
|
||||
if (_session == null) return;
|
||||
|
||||
final msg = Message(role: role, content: content, tokens: tokens);
|
||||
final msg = Message(role: role, content: content, tokens: tokens, contextTokens: contextTokens);
|
||||
|
||||
_session!.messages.add(msg);
|
||||
_session!.updated = DateTime.now().toUtc();
|
||||
@@ -41,10 +41,23 @@ class ConversationHistory {
|
||||
content: "${lastMessage.content}$text",
|
||||
timestamp: lastMessage.timestamp,
|
||||
tokens: lastMessage.tokens,
|
||||
contextTokens: lastMessage.contextTokens,
|
||||
);
|
||||
_session!.updated = DateTime.now().toUtc();
|
||||
}
|
||||
|
||||
void setLastMessageContextTokens(int contextTokens) {
|
||||
if (_session == null || _session!.messages.isEmpty) return;
|
||||
final last = _session!.messages.last;
|
||||
_session!.messages[_session!.messages.length - 1] = Message(
|
||||
role: last.role,
|
||||
content: last.content,
|
||||
timestamp: last.timestamp,
|
||||
tokens: last.tokens,
|
||||
contextTokens: contextTokens,
|
||||
);
|
||||
}
|
||||
|
||||
void removeLastMessage() {
|
||||
if (_session == null || _session!.messages.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
// Parity exception: Claude Code stores sessions in ~/.claude/projects/<sanitized-cwd>/<id>.jsonl
|
||||
// The Agency uses <workingDirectory>/.the_agency/sessions/<id>.json instead, because the UI
|
||||
// is multi-project and it makes more sense for session data to live alongside the project.
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "../local_state.dart";
|
||||
import "package:path/path.dart" as p;
|
||||
|
||||
import "session_types.dart";
|
||||
|
||||
const _encoder = JsonEncoder.withIndent(" ");
|
||||
|
||||
// sessions live in ~/.clawd_code/sessions/{id}.json
|
||||
String getSessionsDir() {
|
||||
return joinPath(getConfigHomeDir(), "sessions");
|
||||
// sessions live in <workingDirectory>/.the_agency/sessions/{id}.json
|
||||
String getProjectAgencyDir(String workingDirectory) {
|
||||
return p.join(workingDirectory, ".the_agency");
|
||||
}
|
||||
|
||||
String _sessionPath(String id) {
|
||||
return joinPath(getSessionsDir(), "$id.json");
|
||||
String getProjectSessionsDir(String workingDirectory) {
|
||||
return p.join(getProjectAgencyDir(workingDirectory), "sessions");
|
||||
}
|
||||
|
||||
String _sessionPath(String workingDirectory, String id) {
|
||||
return p.join(getProjectSessionsDir(workingDirectory), "$id.json");
|
||||
}
|
||||
|
||||
class SessionStore {
|
||||
@@ -20,20 +29,22 @@ class SessionStore {
|
||||
|
||||
static final SessionStore instance = SessionStore._();
|
||||
|
||||
|
||||
Future<void> saveSession(ConversationSession session) async {
|
||||
final dir = Directory(getSessionsDir());
|
||||
final workingDir = session.workingDirectory;
|
||||
if (workingDir == null || workingDir.isEmpty) return;
|
||||
|
||||
final dir = Directory(getProjectSessionsDir(workingDir));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final file = File(_sessionPath(session.id));
|
||||
final file = File(_sessionPath(workingDir, session.id));
|
||||
final json = _encoder.convert(session.toJson());
|
||||
await file.writeAsString("$json\n");
|
||||
}
|
||||
|
||||
Future<ConversationSession?> loadSession(String id) async {
|
||||
final file = File(_sessionPath(id));
|
||||
Future<ConversationSession?> loadSession(String id, {required String workingDirectory}) async {
|
||||
final file = File(_sessionPath(workingDirectory, id));
|
||||
if (!await file.exists()) return null;
|
||||
|
||||
try {
|
||||
@@ -43,15 +54,15 @@ class SessionStore {
|
||||
return ConversationSession.fromJson(decoded);
|
||||
}
|
||||
} catch (_) {
|
||||
// corrupt file - just return null
|
||||
// corrupt file — return null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// returns summaries sorted newest first
|
||||
Future<List<SessionSummary>> listSessions() async {
|
||||
final dir = Directory(getSessionsDir());
|
||||
// lists sessions for a single project, sorted newest first
|
||||
Future<List<SessionSummary>> listSessionsForProject(String workingDirectory) async {
|
||||
final dir = Directory(getProjectSessionsDir(workingDirectory));
|
||||
if (!await dir.exists()) return <SessionSummary>[];
|
||||
|
||||
final summaries = <SessionSummary>[];
|
||||
@@ -76,39 +87,23 @@ class SessionStore {
|
||||
return summaries;
|
||||
}
|
||||
|
||||
Future<bool> deleteSession(String id) async {
|
||||
final file = File(_sessionPath(id));
|
||||
// lists all sessions across multiple projects, sorted newest first
|
||||
Future<List<SessionSummary>> listAllSessions(List<String> workingDirectories) async {
|
||||
final all = <SessionSummary>[];
|
||||
|
||||
for (final dir in workingDirectories) {
|
||||
all.addAll(await listSessionsForProject(dir));
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.updated.compareTo(a.updated));
|
||||
return all;
|
||||
}
|
||||
|
||||
Future<bool> deleteSession(String id, {required String workingDirectory}) async {
|
||||
final file = File(_sessionPath(workingDirectory, id));
|
||||
if (!await file.exists()) return false;
|
||||
|
||||
await file.delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
// case insensitive search by name
|
||||
Future<ConversationSession?> findSessionByName(String name) async {
|
||||
final dir = Directory(getSessionsDir());
|
||||
if (!await dir.exists()) return null;
|
||||
|
||||
final lowerName = name.toLowerCase();
|
||||
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! File) continue;
|
||||
if (!entity.path.endsWith(".json")) continue;
|
||||
|
||||
try {
|
||||
final raw = await entity.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final sess = ConversationSession.fromJson(decoded);
|
||||
if (sess.name.toLowerCase() == lowerName) {
|
||||
return sess;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class Message {
|
||||
required this.content,
|
||||
DateTime? timestamp,
|
||||
this.tokens,
|
||||
this.contextTokens,
|
||||
}) : timestamp = timestamp ?? DateTime.now().toUtc();
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) {
|
||||
@@ -17,6 +18,7 @@ class Message {
|
||||
? DateTime.tryParse(json["timestamp"] as String)
|
||||
: null,
|
||||
tokens: json["tokens"] as int?,
|
||||
contextTokens: json["contextTokens"] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,12 +29,17 @@ class Message {
|
||||
// approx token count - may be null if not tracked
|
||||
final int? tokens;
|
||||
|
||||
// full context window size from the last API response usage field
|
||||
// (input + cache_creation + cache_read + output), same as Claude Code
|
||||
final int? contextTokens;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": timestamp.toIso8601String(),
|
||||
if (tokens != null) "tokens": tokens,
|
||||
if (contextTokens != null) "contextTokens": contextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
// CLAUDE.md loader — mirrors claude code's claudemd.ts behaviour.
|
||||
//
|
||||
// Loading order (later = higher priority, model pays more attention):
|
||||
// 1. Managed (/Library/Application Support/ClaudeCode/CLAUDE.md on mac,
|
||||
// /etc/claude-code/CLAUDE.md on linux,
|
||||
// C:\Program Files\ClaudeCode\CLAUDE.md on windows)
|
||||
// 2. User (~/.claude/CLAUDE.md, ~/.claude/rules/*.md)
|
||||
// 3. Project (CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md)
|
||||
// walking up from cwd to root, root first (cwd wins)
|
||||
// 4. Local (CLAUDE.local.md, same traversal)
|
||||
//
|
||||
// @include directive:
|
||||
// @path, @./relative, @~/home, @/absolute — only in text nodes,
|
||||
// not inside fenced code blocks. Max depth 5. Circular refs skipped.
|
||||
//
|
||||
// HTML comment stripping:
|
||||
// Block-level HTML comments (lines starting with <!--) are stripped.
|
||||
// Comments inside fenced code blocks are preserved.
|
||||
|
||||
import "dart:io";
|
||||
import "package:path/path.dart" as p;
|
||||
|
||||
import "frontmatter_parser.dart";
|
||||
import "memory_file_info.dart";
|
||||
import "memory_types.dart";
|
||||
|
||||
|
||||
const int _maxIncludeDepth = 5;
|
||||
|
||||
// Only text-like extensions are allowed in @include directives
|
||||
// (prevents loading binary files like images, PDFs, etc.)
|
||||
const _textFileExtensions = {
|
||||
".md", ".txt", ".text",
|
||||
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
|
||||
".html", ".htm", ".css", ".scss", ".sass", ".less",
|
||||
".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
|
||||
".py", ".pyi", ".pyw",
|
||||
".rb", ".erb", ".rake",
|
||||
".go",
|
||||
".rs",
|
||||
".java", ".kt", ".kts", ".scala",
|
||||
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx",
|
||||
".cs",
|
||||
".swift",
|
||||
".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd",
|
||||
".env", ".ini", ".cfg", ".conf", ".config", ".properties",
|
||||
".sql", ".graphql", ".gql",
|
||||
".proto",
|
||||
".vue", ".svelte", ".astro",
|
||||
".ejs", ".hbs", ".pug", ".jade",
|
||||
".php", ".pl", ".pm", ".lua", ".r", ".R", ".dart",
|
||||
".ex", ".exs", ".erl", ".hrl",
|
||||
".clj", ".cljs", ".cljc", ".edn",
|
||||
".hs", ".lhs", ".elm",
|
||||
".ml", ".mli",
|
||||
".f", ".f90", ".f95", ".for",
|
||||
".cmake", ".make", ".makefile", ".gradle", ".sbt",
|
||||
".rst", ".adoc", ".asciidoc", ".org", ".tex", ".latex",
|
||||
".lock", ".log", ".diff", ".patch",
|
||||
};
|
||||
|
||||
const _memoryInstructionPrompt =
|
||||
"Codebase and user instructions are shown below. Be sure to adhere to these"
|
||||
" instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you"
|
||||
" MUST follow them exactly as written.";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Platform paths
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
String _getManagedFilePath() {
|
||||
if (Platform.isMacOS) return "/Library/Application Support/ClaudeCode";
|
||||
if (Platform.isWindows) return r"C:\Program Files\ClaudeCode";
|
||||
return "/etc/claude-code";
|
||||
}
|
||||
|
||||
String _getClaudeConfigHomeDir() {
|
||||
final override = Platform.environment["CLAUDE_CONFIG_DIR"];
|
||||
if (override != null && override.isNotEmpty) return override;
|
||||
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
|
||||
return p.join(home, ".claude");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// HTML comment stripping
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Strips block-level HTML comments from markdown content.
|
||||
// Comments inside fenced code blocks are preserved.
|
||||
// Unclosed comments (<!-- with no -->) are left in place.
|
||||
String stripHtmlComments(String content) {
|
||||
if (!content.contains("<!--")) return content;
|
||||
|
||||
final lines = content.split("\n");
|
||||
final result = StringBuffer();
|
||||
var inFence = false;
|
||||
String? fenceChar;
|
||||
var inComment = false;
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
final raw = line.trimLeft();
|
||||
|
||||
// Track fenced code blocks (``` or ~~~)
|
||||
if (!inComment) {
|
||||
if (!inFence) {
|
||||
if (raw.startsWith("```") || raw.startsWith("~~~")) {
|
||||
inFence = true;
|
||||
fenceChar = raw.startsWith("```") ? "```" : "~~~";
|
||||
result.write(line);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (raw.startsWith(fenceChar!)) {
|
||||
inFence = false;
|
||||
fenceChar = null;
|
||||
}
|
||||
result.write(line);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
result.write(line);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// block-level HTML comment starts with <!-- (up to 3 leading spaces allowed)
|
||||
if (!inComment && raw.startsWith("<!--")) {
|
||||
// check if it closes on the same line/block
|
||||
final commentSpan = RegExp(r"<!--[\s\S]*?-->");
|
||||
final residue = line.replaceAll(commentSpan, "");
|
||||
|
||||
if (line.contains("-->")) {
|
||||
// comment closed — keep any residue
|
||||
if (residue.trim().isNotEmpty) {
|
||||
result.write(residue);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
// multi-line comment — enter comment mode
|
||||
inComment = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inComment) {
|
||||
if (line.contains("-->")) {
|
||||
inComment = false;
|
||||
// strip up to and including -->
|
||||
final after = line.substring(line.indexOf("-->") + 3);
|
||||
if (after.trim().isNotEmpty) {
|
||||
result.write(after);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
}
|
||||
}
|
||||
// skip lines inside comment block
|
||||
continue;
|
||||
}
|
||||
|
||||
result.write(line);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// @include path extraction
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mirrors extractIncludePathsFromTokens from Claude Code.
|
||||
// Finds @path patterns in text nodes (skips code blocks and HTML comments).
|
||||
List<String> _extractIncludePaths(String content, String fileDir) {
|
||||
final absolutePaths = <String>{};
|
||||
final includeRegex = RegExp(r"(?:^|\s)@((?:[^\s\\]|\\ )+)");
|
||||
|
||||
final lines = content.split("\n");
|
||||
var inFence = false;
|
||||
String? fenceChar;
|
||||
var inComment = false;
|
||||
|
||||
for (final line in lines) {
|
||||
final raw = line.trimLeft();
|
||||
|
||||
// Track fenced code blocks
|
||||
if (!inFence && !inComment) {
|
||||
if (raw.startsWith("```") || raw.startsWith("~~~")) {
|
||||
inFence = true;
|
||||
fenceChar = raw.startsWith("```") ? "```" : "~~~";
|
||||
continue;
|
||||
}
|
||||
} else if (inFence) {
|
||||
if (raw.startsWith(fenceChar!)) {
|
||||
inFence = false;
|
||||
fenceChar = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track HTML comment blocks (skip @includes inside them)
|
||||
if (!inComment && raw.startsWith("<!--")) {
|
||||
if (line.contains("-->")) {
|
||||
// single-line comment — strip and check residue
|
||||
final residue = line.replaceAll(RegExp(r"<!--[\s\S]*?-->"), "");
|
||||
_extractPathsFromText(residue, fileDir, includeRegex, absolutePaths);
|
||||
} else {
|
||||
inComment = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inComment) {
|
||||
if (line.contains("-->")) inComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
_extractPathsFromText(line, fileDir, includeRegex, absolutePaths);
|
||||
}
|
||||
|
||||
return absolutePaths.toList();
|
||||
}
|
||||
|
||||
void _extractPathsFromText(
|
||||
String text,
|
||||
String fileDir,
|
||||
RegExp includeRegex,
|
||||
Set<String> absolutePaths,
|
||||
) {
|
||||
for (final match in includeRegex.allMatches(text)) {
|
||||
var path = match.group(1);
|
||||
if (path == null || path.isEmpty) continue;
|
||||
|
||||
// strip fragment identifiers
|
||||
final hashIdx = path.indexOf("#");
|
||||
if (hashIdx != -1) path = path.substring(0, hashIdx);
|
||||
if (path.isEmpty) continue;
|
||||
|
||||
// unescape spaces
|
||||
path = path.replaceAll(r"\ ", " ");
|
||||
|
||||
final isValid = path.startsWith("./") ||
|
||||
path.startsWith("~/") ||
|
||||
(path.startsWith("/") && path != "/") ||
|
||||
(!path.startsWith("@") &&
|
||||
!RegExp(r"^[#%^&*()]+").hasMatch(path) &&
|
||||
RegExp(r"^[a-zA-Z0-9._-]").hasMatch(path));
|
||||
|
||||
if (!isValid) continue;
|
||||
|
||||
final resolved = _expandPath(path, fileDir);
|
||||
absolutePaths.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
String _expandPath(String path, String baseDir) {
|
||||
if (path.startsWith("~/")) {
|
||||
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
|
||||
return p.join(home, path.substring(2));
|
||||
}
|
||||
if (p.isAbsolute(path)) return path;
|
||||
return p.normalize(p.join(baseDir, path));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Frontmatter paths extraction
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Returns null if no paths restriction, empty/non-empty list if restricted.
|
||||
// Strips /** suffix like Claude Code does.
|
||||
List<String>? _parseFrontmatterPaths(Map<String, dynamic> frontmatter) {
|
||||
final raw = frontmatter["paths"];
|
||||
if (raw == null) return null;
|
||||
|
||||
final patterns = splitPathInFrontmatter(raw)
|
||||
.map((p) => p.endsWith("/**") ? p.substring(0, p.length - 3) : p)
|
||||
.where((p) => p.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
// if all patterns are ** (match-all), treat as no restriction
|
||||
if (patterns.isEmpty || patterns.every((p) => p == "**")) return null;
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Core file processor
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Recursively processes a memory file and all its @include references.
|
||||
// Returns includes-first list (same order as Claude Code).
|
||||
Future<List<MemoryFileInfo>> processMemoryFile(
|
||||
String filePath,
|
||||
MemoryType type,
|
||||
Set<String> processedPaths, {
|
||||
|
||||
bool includeExternal = false,
|
||||
int depth = 0,
|
||||
String? parent,
|
||||
String? originalCwd,
|
||||
}) async {
|
||||
final normalizedPath = p.normalize(filePath);
|
||||
|
||||
if (processedPaths.contains(normalizedPath) || depth >= _maxIncludeDepth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extension check — skip non-text files (like Claude Code does for @include)
|
||||
final ext = p.extension(filePath).toLowerCase();
|
||||
if (ext.isNotEmpty && !_textFileExtensions.contains(ext)) return [];
|
||||
|
||||
processedPaths.add(normalizedPath);
|
||||
|
||||
String rawContent;
|
||||
try {
|
||||
final file = File(filePath);
|
||||
|
||||
// resolve symlinks
|
||||
String resolvedPath = filePath;
|
||||
try {
|
||||
resolvedPath = await file.resolveSymbolicLinks();
|
||||
if (resolvedPath != normalizedPath) {
|
||||
processedPaths.add(p.normalize(resolvedPath));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!await file.exists()) return [];
|
||||
rawContent = await file.readAsString();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// parse frontmatter
|
||||
final parsed = parseFrontmatter(rawContent);
|
||||
final globs = _parseFrontmatterPaths(parsed.frontmatter);
|
||||
|
||||
// strip HTML block comments
|
||||
final stripped = stripHtmlComments(parsed.content);
|
||||
if (stripped.trim().isEmpty) return [];
|
||||
|
||||
final memFile = MemoryFileInfo(
|
||||
path: filePath,
|
||||
type: type,
|
||||
content: stripped.trim(),
|
||||
parent: parent,
|
||||
globs: globs,
|
||||
);
|
||||
|
||||
final result = <MemoryFileInfo>[memFile];
|
||||
|
||||
// process @include directives
|
||||
final fileDir = p.dirname(filePath);
|
||||
final includePaths = _extractIncludePaths(stripped, fileDir);
|
||||
|
||||
for (final includePath in includePaths) {
|
||||
final isExternal = originalCwd != null &&
|
||||
!_pathIsUnder(includePath, originalCwd);
|
||||
if (isExternal && !includeExternal) continue;
|
||||
|
||||
final included = await processMemoryFile(
|
||||
includePath,
|
||||
type,
|
||||
processedPaths,
|
||||
includeExternal: includeExternal,
|
||||
depth: depth + 1,
|
||||
parent: filePath,
|
||||
originalCwd: originalCwd,
|
||||
);
|
||||
result.addAll(included);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _pathIsUnder(String path, String root) {
|
||||
final normPath = p.normalize(path);
|
||||
final normRoot = p.normalize(root);
|
||||
return normPath.startsWith(normRoot + p.separator) || normPath == normRoot;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// rules directory processor
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Processes all .md files in a .claude/rules/ directory (and subdirectories).
|
||||
// conditionalRule=false → keep files WITHOUT a paths: frontmatter
|
||||
// conditionalRule=true → keep files WITH a paths: frontmatter
|
||||
Future<List<MemoryFileInfo>> processMdRules(
|
||||
String rulesDir,
|
||||
MemoryType type,
|
||||
Set<String> processedPaths, {
|
||||
|
||||
bool includeExternal = false,
|
||||
bool conditionalRule = false,
|
||||
Set<String>? visitedDirs,
|
||||
String? originalCwd,
|
||||
}) async {
|
||||
visitedDirs ??= {};
|
||||
|
||||
String resolvedDir;
|
||||
try {
|
||||
resolvedDir = await Directory(rulesDir).resolveSymbolicLinks();
|
||||
} catch (_) {
|
||||
resolvedDir = rulesDir;
|
||||
}
|
||||
|
||||
if (visitedDirs.contains(resolvedDir)) return [];
|
||||
visitedDirs.add(resolvedDir);
|
||||
|
||||
final dir = Directory(resolvedDir);
|
||||
List<FileSystemEntity> entries;
|
||||
try {
|
||||
entries = dir.listSync();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.path.compareTo(b.path));
|
||||
|
||||
final result = <MemoryFileInfo>[];
|
||||
|
||||
for (final entry in entries) {
|
||||
if (entry is Directory) {
|
||||
result.addAll(await processMdRules(
|
||||
entry.path,
|
||||
type,
|
||||
processedPaths,
|
||||
includeExternal: includeExternal,
|
||||
conditionalRule: conditionalRule,
|
||||
visitedDirs: visitedDirs,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
} else if (entry is File && entry.path.endsWith(".md")) {
|
||||
String resolvedEntry;
|
||||
try {
|
||||
resolvedEntry = await entry.resolveSymbolicLinks();
|
||||
} catch (_) {
|
||||
resolvedEntry = entry.path;
|
||||
}
|
||||
|
||||
final files = await processMemoryFile(
|
||||
resolvedEntry,
|
||||
type,
|
||||
processedPaths,
|
||||
includeExternal: includeExternal,
|
||||
originalCwd: originalCwd,
|
||||
);
|
||||
// filter by conditional/non-conditional
|
||||
result.addAll(files.where((f) => conditionalRule ? f.globs != null : f.globs == null));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Git root detection (for nested worktree handling)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
String? _findGitRoot(String startDir) {
|
||||
var current = p.normalize(startDir);
|
||||
final root = p.rootPrefix(current);
|
||||
|
||||
while (true) {
|
||||
final gitDir = Directory(p.join(current, ".git"));
|
||||
if (gitDir.existsSync()) return current;
|
||||
final parent = p.dirname(current);
|
||||
if (parent == current || current == root) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// In a git worktree, .git is a file (not a dir) containing the gitdir path.
|
||||
// The canonical root is the main repo that owns the worktree.
|
||||
String? _findCanonicalGitRoot(String startDir) {
|
||||
var current = p.normalize(startDir);
|
||||
final root = p.rootPrefix(current);
|
||||
|
||||
while (true) {
|
||||
final gitEntity = p.join(current, ".git");
|
||||
final gitFile = File(gitEntity);
|
||||
final gitDir = Directory(gitEntity);
|
||||
|
||||
if (gitFile.existsSync() && !gitDir.existsSync()) {
|
||||
// worktree: .git is a file like "gitdir: ../../.git/worktrees/name"
|
||||
try {
|
||||
final content = gitFile.readAsStringSync().trim();
|
||||
if (content.startsWith("gitdir:")) {
|
||||
final gitdirPath = content.substring("gitdir:".length).trim();
|
||||
final resolved = p.normalize(p.isAbsolute(gitdirPath)
|
||||
? gitdirPath
|
||||
: p.join(current, gitdirPath));
|
||||
// go up from .git/worktrees/<name> → main repo root
|
||||
final mainGit = p.dirname(p.dirname(p.dirname(resolved)));
|
||||
return mainGit;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (gitDir.existsSync()) return current;
|
||||
|
||||
final parent = p.dirname(current);
|
||||
if (parent == current || current == root) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Main entry point
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Loads all CLAUDE.md files in the same order as Claude Code.
|
||||
// Returns a list of MemoryFileInfo objects, later entries have higher priority.
|
||||
Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
|
||||
final result = <MemoryFileInfo>[];
|
||||
final processedPaths = <String>{};
|
||||
final originalCwd = workingDirectory != null ? p.normalize(workingDirectory) : null;
|
||||
|
||||
// 1. Managed memory
|
||||
final managedDir = _getManagedFilePath();
|
||||
final managedMd = p.join(managedDir, "CLAUDE.md");
|
||||
|
||||
result.addAll(await processMemoryFile(
|
||||
managedMd,
|
||||
MemoryType.managed,
|
||||
processedPaths,
|
||||
includeExternal: true,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
result.addAll(await processMdRules(
|
||||
p.join(managedDir, ".claude", "rules"),
|
||||
MemoryType.managed,
|
||||
processedPaths,
|
||||
includeExternal: true,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
// 2. User memory
|
||||
final userConfigDir = _getClaudeConfigHomeDir();
|
||||
final userMd = p.join(userConfigDir, "CLAUDE.md");
|
||||
|
||||
result.addAll(await processMemoryFile(
|
||||
userMd,
|
||||
MemoryType.user,
|
||||
processedPaths,
|
||||
includeExternal: true,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
result.addAll(await processMdRules(
|
||||
p.join(userConfigDir, "rules"),
|
||||
MemoryType.user,
|
||||
processedPaths,
|
||||
includeExternal: true,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
if (originalCwd != null) {
|
||||
// 3 & 4. Project + Local memory — walk up from cwd to root
|
||||
final dirs = _buildAncestorChain(originalCwd);
|
||||
|
||||
// git worktree detection — same logic as Claude Code
|
||||
final gitRoot = _findGitRoot(originalCwd);
|
||||
final canonicalRoot = _findCanonicalGitRoot(originalCwd);
|
||||
final isNestedWorktree = gitRoot != null &&
|
||||
canonicalRoot != null &&
|
||||
p.normalize(gitRoot) != p.normalize(canonicalRoot) &&
|
||||
_pathIsUnder(gitRoot, canonicalRoot);
|
||||
|
||||
// process from root → cwd (so cwd files are loaded last = highest priority)
|
||||
for (final dir in dirs) {
|
||||
final skipProject = isNestedWorktree &&
|
||||
_pathIsUnder(dir, canonicalRoot!) &&
|
||||
!_pathIsUnder(dir, gitRoot!);
|
||||
|
||||
if (!skipProject) {
|
||||
// CLAUDE.md
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, "CLAUDE.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
// .claude/CLAUDE.md
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, ".claude", "CLAUDE.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
// .claude/rules/*.md
|
||||
result.addAll(await processMdRules(
|
||||
p.join(dir, ".claude", "rules"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
}
|
||||
|
||||
// CLAUDE.local.md (not skipped even in nested worktrees)
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, "CLAUDE.local.md"),
|
||||
MemoryType.local,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
}
|
||||
|
||||
// env var for additional directories
|
||||
final additionalDirsEnv =
|
||||
Platform.environment["CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD"];
|
||||
if (additionalDirsEnv != null && additionalDirsEnv.isNotEmpty &&
|
||||
_isEnvTruthy(additionalDirsEnv)) {
|
||||
final additionalDirs = _getAdditionalDirs();
|
||||
|
||||
for (final dir in additionalDirs) {
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, "CLAUDE.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, ".claude", "CLAUDE.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
result.addAll(await processMdRules(
|
||||
p.join(dir, ".claude", "rules"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Builds ancestor chain from filesystem root down to dir (inclusive)
|
||||
List<String> _buildAncestorChain(String dir) {
|
||||
final chain = <String>[];
|
||||
var current = p.normalize(dir);
|
||||
final root = p.rootPrefix(current);
|
||||
|
||||
while (true) {
|
||||
chain.add(current);
|
||||
final parent = p.dirname(current);
|
||||
if (parent == current || current == root) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return chain.reversed.toList();
|
||||
}
|
||||
|
||||
bool _isEnvTruthy(String value) {
|
||||
final v = value.toLowerCase().trim();
|
||||
return v == "1" || v == "true" || v == "yes";
|
||||
}
|
||||
|
||||
List<String> _getAdditionalDirs() {
|
||||
// Claude Code gets these from bootstrap state (--add-dir flag).
|
||||
// The Agency doesn't support --add-dir yet, so return empty.
|
||||
return [];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Assembler (mirrors getClaudeMds)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Assembles memory files into a single string for injection into the system prompt.
|
||||
// Mirrors getClaudeMds() from Claude Code.
|
||||
String getClaudeMds(List<MemoryFileInfo> memoryFiles) {
|
||||
final memories = <String>[];
|
||||
|
||||
for (final file in memoryFiles) {
|
||||
final content = file.content.trim();
|
||||
if (content.isEmpty) continue;
|
||||
|
||||
memories.add("Contents of ${file.path}${file.type.label}:\n\n$content");
|
||||
}
|
||||
|
||||
if (memories.isEmpty) return "";
|
||||
|
||||
return "$_memoryInstructionPrompt\n\n${memories.join("\n\n")}";
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import "package:yaml/yaml.dart";
|
||||
|
||||
class ParsedFrontmatter {
|
||||
final Map<String, dynamic> frontmatter;
|
||||
final String content;
|
||||
|
||||
const ParsedFrontmatter({required this.frontmatter, required this.content});
|
||||
}
|
||||
|
||||
final _frontmatterRegex = RegExp(r"^---\s*\n([\s\S]*?)---\s*\n?");
|
||||
|
||||
ParsedFrontmatter parseFrontmatter(String markdown) {
|
||||
final match = _frontmatterRegex.firstMatch(markdown);
|
||||
if (match == null) {
|
||||
return ParsedFrontmatter(frontmatter: {}, content: markdown);
|
||||
}
|
||||
|
||||
final frontmatterText = match.group(1) ?? "";
|
||||
final content = markdown.substring(match.end);
|
||||
|
||||
Map<String, dynamic> frontmatter = {};
|
||||
try {
|
||||
final parsed = loadYaml(frontmatterText);
|
||||
if (parsed is YamlMap) {
|
||||
frontmatter = _deepConvert(parsed) as Map<String, dynamic>;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return ParsedFrontmatter(frontmatter: frontmatter, content: content);
|
||||
}
|
||||
|
||||
dynamic _deepConvert(dynamic value) {
|
||||
if (value is YamlMap) {
|
||||
return {
|
||||
for (final entry in value.entries)
|
||||
entry.key.toString(): _deepConvert(entry.value),
|
||||
};
|
||||
}
|
||||
if (value is YamlList) {
|
||||
return [for (final item in value) _deepConvert(item)];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Splits the frontmatter `paths:` value into individual glob patterns.
|
||||
// Accepts a comma-separated string or a list.
|
||||
// Handles brace expansion: src/*.{ts,tsx} → [src/*.ts, src/*.tsx]
|
||||
List<String> splitPathInFrontmatter(dynamic input) {
|
||||
if (input is List) {
|
||||
return input.expand((e) => splitPathInFrontmatter(e.toString())).toList();
|
||||
}
|
||||
if (input is! String) return [];
|
||||
|
||||
// split by comma while respecting braces
|
||||
final parts = <String>[];
|
||||
var current = StringBuffer();
|
||||
var braceDepth = 0;
|
||||
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
final char = input[i];
|
||||
if (char == "{") {
|
||||
braceDepth++;
|
||||
current.write(char);
|
||||
} else if (char == "}") {
|
||||
braceDepth--;
|
||||
current.write(char);
|
||||
} else if (char == "," && braceDepth == 0) {
|
||||
final trimmed = current.toString().trim();
|
||||
if (trimmed.isNotEmpty) parts.add(trimmed);
|
||||
current.clear();
|
||||
} else {
|
||||
current.write(char);
|
||||
}
|
||||
}
|
||||
|
||||
final last = current.toString().trim();
|
||||
if (last.isNotEmpty) parts.add(last);
|
||||
|
||||
return parts
|
||||
.where((p) => p.isNotEmpty)
|
||||
.expand(_expandBraces)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<String> _expandBraces(String pattern) {
|
||||
final braceMatch = RegExp(r"^([^{]*)\{([^}]+)\}(.*)$").firstMatch(pattern);
|
||||
if (braceMatch == null) return [pattern];
|
||||
|
||||
final prefix = braceMatch.group(1) ?? "";
|
||||
final alternatives = braceMatch.group(2) ?? "";
|
||||
final suffix = braceMatch.group(3) ?? "";
|
||||
|
||||
return alternatives
|
||||
.split(",")
|
||||
.map((alt) => alt.trim())
|
||||
.expand((alt) => _expandBraces("$prefix$alt$suffix"))
|
||||
.toList();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import "memory_types.dart";
|
||||
|
||||
class MemoryFileInfo {
|
||||
|
||||
final String path;
|
||||
final MemoryType type;
|
||||
final String content;
|
||||
final String? parent;
|
||||
|
||||
// glob patterns from frontmatter `paths:` field
|
||||
// null means no paths restriction (applies to all files)
|
||||
final List<String>? globs;
|
||||
|
||||
const MemoryFileInfo({
|
||||
required this.path,
|
||||
required this.type,
|
||||
required this.content,
|
||||
this.parent,
|
||||
this.globs,
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
enum MemoryType { managed, user, project, local }
|
||||
|
||||
extension MemoryTypeDescription on MemoryType {
|
||||
String get label {
|
||||
switch (this) {
|
||||
case MemoryType.managed:
|
||||
return " (user's private global instructions for all projects)";
|
||||
case MemoryType.user:
|
||||
return " (user's private global instructions for all projects)";
|
||||
case MemoryType.project:
|
||||
return " (project instructions, checked into the codebase)";
|
||||
case MemoryType.local:
|
||||
return " (user's private project instructions, not checked in)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
String buildDefaultSystemPrompt({
|
||||
String? appendSystemPrompt,
|
||||
String? customSystemPrompt,
|
||||
String? claudeMd,
|
||||
}) {
|
||||
if (customSystemPrompt != null && customSystemPrompt.trim().isNotEmpty) {
|
||||
final parts = <String>[customSystemPrompt];
|
||||
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
|
||||
parts.add(appendSystemPrompt);
|
||||
}
|
||||
if (claudeMd != null && claudeMd.trim().isNotEmpty) {
|
||||
parts.add(claudeMd);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
final parts = <String>[
|
||||
"You are Claude Code, an AI assistant for software engineering.",
|
||||
"You are The Agency, an AI assistant for software engineering.",
|
||||
];
|
||||
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
|
||||
parts.add(appendSystemPrompt);
|
||||
}
|
||||
if (claudeMd != null && claudeMd.trim().isNotEmpty) {
|
||||
parts.add(claudeMd);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import "base_tool.dart";
|
||||
|
||||
/// A tool for spawning and coordinating AI agents for collaborative work
|
||||
class AgentTool extends BaseTool {
|
||||
@override
|
||||
final String name = "Agent";
|
||||
|
||||
@override
|
||||
final String description = "Spawn and coordinate AI agents for collaborative work";
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final agentType = input["agent_type"] as String? ?? "general-purpose";
|
||||
final task = input["task"] as String? ?? "general task";
|
||||
final modelName = input["model"] as String?;
|
||||
final teamName = input["team_name"] as String?;
|
||||
|
||||
final agentId = 'agent_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
// Different agent types with different response patterns
|
||||
final responses = <String, String>{
|
||||
'general-purpose': "I've analyzed the task '$task' and here's my comprehensive response...",
|
||||
'researcher': "After researching '$task', I found several key insights about this topic...",
|
||||
'tester': "I've tested the implementation for '$task' and found a few issues that need addressing...",
|
||||
'reviewer': "Here's my code review for '$task' with suggestions for improvement...",
|
||||
'planner': "I've created a detailed plan for '$task' with the following steps...",
|
||||
};
|
||||
|
||||
final result = responses[agentType] ?? "Agent completed the task successfully.";
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Agent $agentId ($agentType) has been spawned.');
|
||||
if (modelName != null) {
|
||||
buffer.writeln('Model: $modelName');
|
||||
}
|
||||
if (teamName != null) {
|
||||
buffer.writeln('Team: $teamName');
|
||||
}
|
||||
buffer.writeln();
|
||||
buffer.writeln('Task: $task');
|
||||
buffer.writeln();
|
||||
buffer.writeln(result);
|
||||
buffer.writeln();
|
||||
buffer.writeln('Note: In a full implementation, this would spawn an actual AI agent.');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../services/task_executor.dart';
|
||||
import 'base_tool.dart';
|
||||
|
||||
/// Tool for executing background tasks with real process management
|
||||
class ExecuteTaskTool extends BaseTool {
|
||||
@override
|
||||
final String name = 'ExecuteTask';
|
||||
|
||||
@override
|
||||
final String description =
|
||||
'Execute a task as a background process and get real-time status';
|
||||
|
||||
final TaskExecutor _executor = TaskExecutor();
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final action = input['action'] as String? ?? 'execute';
|
||||
final taskId = input['task_id'] as String?;
|
||||
final command = input['command'] as String?;
|
||||
final arguments = _readStringList(input['arguments']);
|
||||
final processId = input['process_id'] as String?;
|
||||
final workingDirectory = input['working_directory'] as String? ??
|
||||
Directory.current.path;
|
||||
|
||||
switch (action.toLowerCase()) {
|
||||
case 'execute':
|
||||
if (taskId == null || command == null) {
|
||||
return 'Error: task_id and command are required for execute action';
|
||||
}
|
||||
return await _executeTask(
|
||||
taskId: taskId,
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
|
||||
case 'status':
|
||||
if (processId == null) {
|
||||
return 'Error: process_id is required for status action';
|
||||
}
|
||||
return _getStatus(processId);
|
||||
|
||||
case 'result':
|
||||
if (processId == null) {
|
||||
return 'Error: process_id is required for result action';
|
||||
}
|
||||
return _getResult(processId);
|
||||
|
||||
case 'cancel':
|
||||
if (processId == null) {
|
||||
return 'Error: process_id is required for cancel action';
|
||||
}
|
||||
final force = input['force'] as bool? ?? false;
|
||||
return await _cancelTask(processId, force: force);
|
||||
|
||||
case 'list':
|
||||
return _listActiveTasks();
|
||||
|
||||
default:
|
||||
return 'Error: Unknown action "$action". Available actions: execute, status, result, cancel, list';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _executeTask({
|
||||
required String taskId,
|
||||
required String command,
|
||||
required List<String> arguments,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
try {
|
||||
final processId = await _executor.executeTask(
|
||||
taskId: taskId,
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
|
||||
return '''Executing task: $taskId
|
||||
Process ID: $processId
|
||||
Command: $command ${arguments.join(' ')}
|
||||
Working directory: $workingDirectory
|
||||
|
||||
Use ExecuteTask:status process_id="$processId" to check status
|
||||
Use ExecuteTask:result process_id="$processId" to get results
|
||||
Use ExecuteTask:cancel process_id="$processId" to stop''';
|
||||
} catch (e) {
|
||||
return 'Error: ${e.toString()}';
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatus(String processId) {
|
||||
final status = _executor.getTaskStatus(processId);
|
||||
final tasks = _executor.getActiveTasks();
|
||||
|
||||
final taskInfo = tasks.firstWhere(
|
||||
(t) => t['id'] == processId,
|
||||
orElse: () => <String, dynamic>{},
|
||||
);
|
||||
|
||||
if (taskInfo.isEmpty) {
|
||||
return 'Task not found or already completed';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Process: $processId');
|
||||
buffer.writeln('Status: ${status.name}');
|
||||
buffer.writeln('Command: ${taskInfo['command']}');
|
||||
buffer.writeln('Duration: ${taskInfo['duration_ms']}ms');
|
||||
buffer.writeln('Output lines: ${taskInfo['output_lines']}');
|
||||
buffer.writeln('Error lines: ${taskInfo['error_lines']}');
|
||||
|
||||
if (status == TaskExecutionStatus.completed ||
|
||||
status == TaskExecutionStatus.failed) {
|
||||
buffer.writeln('Exit code: ${taskInfo['exit_code']}');
|
||||
buffer.writeln('\nUse ExecuteTask:result process_id="$processId" to get full output');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _getResult(String processId) {
|
||||
final result = _executor.getResult(processId);
|
||||
|
||||
if (result == null) {
|
||||
return 'Task still running. Use ExecuteTask:status process_id="$processId"';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Process: $processId');
|
||||
buffer.writeln('Status: ${result.status.name}');
|
||||
buffer.writeln('Exit code: ${result.exitCode}');
|
||||
buffer.writeln('Duration: ${result.duration?.inSeconds}s');
|
||||
buffer.writeln();
|
||||
|
||||
if (result.output.isNotEmpty) {
|
||||
buffer.writeln('--- OUTPUT ---');
|
||||
buffer.writeln(result.output);
|
||||
}
|
||||
|
||||
if (result.errors.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
buffer.writeln('--- ERRORS ---');
|
||||
buffer.writeln(result.errors);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _cancelTask(String processId, {required bool force}) async {
|
||||
final success = await _executor.cancelTask(processId, force: force);
|
||||
|
||||
if (success) {
|
||||
return 'Task $processId cancelled successfully';
|
||||
} else {
|
||||
return 'Error: Could not cancel task $processId';
|
||||
}
|
||||
}
|
||||
|
||||
String _listActiveTasks() {
|
||||
final tasks = _executor.getActiveTasks();
|
||||
|
||||
if (tasks.isEmpty) {
|
||||
return 'No active tasks';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Active tasks (${tasks.length}):');
|
||||
buffer.writeln('─' * 60);
|
||||
|
||||
for (final task in tasks) {
|
||||
final id = task['id'] as String;
|
||||
final command = task['command'] as String;
|
||||
final taskId = task['task_id'] as String;
|
||||
final durationMs = task['duration_ms'] as int?;
|
||||
|
||||
buffer.writeln('$id (Task: $taskId)');
|
||||
buffer.writeln(' Command: $command');
|
||||
buffer.writeln(' Duration: ${durationMs ?? 0}ms');
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
List<String> _readStringList(Object? value) {
|
||||
if (value is! List) {
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
return value
|
||||
.whereType<String>()
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ class GrepTool extends BaseTool {
|
||||
int? contextAfter,
|
||||
}) async {
|
||||
final searchPath = path ?? Directory.current.path;
|
||||
final searchPathType = FileSystemEntity.typeSync(searchPath, followLinks: true);
|
||||
|
||||
final args = <String>["--hidden"];
|
||||
|
||||
@@ -121,6 +122,10 @@ class GrepTool extends BaseTool {
|
||||
}
|
||||
|
||||
if (showLineNumbers && outputMode == "content") args.add("-n");
|
||||
if (searchPathType == FileSystemEntityType.file &&
|
||||
outputMode != "files_with_matches") {
|
||||
args.add("--with-filename");
|
||||
}
|
||||
|
||||
if (outputMode == "content") {
|
||||
if (contextLines != null) {
|
||||
@@ -175,59 +180,47 @@ class GrepTool extends BaseTool {
|
||||
int? headLimit,
|
||||
required int offset,
|
||||
}) async {
|
||||
final searchDir = Directory(path ?? Directory.current.path);
|
||||
if (!await searchDir.exists()) {
|
||||
return "Error: Path does not exist: ${searchDir.path}";
|
||||
final searchPath = path ?? Directory.current.path;
|
||||
final entityType = FileSystemEntity.typeSync(searchPath, followLinks: true);
|
||||
if (entityType == FileSystemEntityType.notFound) {
|
||||
return "Error: Path does not exist: $searchPath";
|
||||
}
|
||||
|
||||
final regex = RegExp(pattern, caseSensitive: !caseInsensitive, multiLine: true);
|
||||
|
||||
final matchedFiles = <String>[];
|
||||
final contentLines = <String>[];
|
||||
var totalMatches = 0;
|
||||
final baseDir = entityType == FileSystemEntityType.directory
|
||||
? searchPath
|
||||
: File(searchPath).parent.path;
|
||||
|
||||
await for (final entity in searchDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is! File) continue;
|
||||
if (entityType == FileSystemEntityType.file) {
|
||||
await _searchFile(
|
||||
file: File(searchPath),
|
||||
regex: regex,
|
||||
glob: glob,
|
||||
outputMode: outputMode,
|
||||
showLineNumbers: showLineNumbers,
|
||||
baseDir: baseDir,
|
||||
matchedFiles: matchedFiles,
|
||||
contentLines: contentLines,
|
||||
);
|
||||
} else {
|
||||
final searchDir = Directory(searchPath);
|
||||
await for (final entity
|
||||
in searchDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is! File) continue;
|
||||
|
||||
// skip vcs dirs
|
||||
final parts = entity.path.split("/");
|
||||
if (parts.any((p) => _vcsSkip.contains(p))) continue;
|
||||
|
||||
// glob filter
|
||||
if (glob != null) {
|
||||
final filename = entity.path.split("/").last;
|
||||
if (!_simpleGlobMatch(glob, filename)) continue;
|
||||
}
|
||||
|
||||
String content;
|
||||
try {
|
||||
content = await entity.readAsString(encoding: utf8);
|
||||
} catch (_) {
|
||||
continue; // skip binary/unreadable
|
||||
}
|
||||
|
||||
final fileMatches = regex.allMatches(content).length;
|
||||
if (fileMatches == 0) continue;
|
||||
|
||||
final relPath = entity.path.startsWith(searchDir.path)
|
||||
? entity.path.substring(searchDir.path.length + 1)
|
||||
: entity.path;
|
||||
|
||||
if (outputMode == "files_with_matches") {
|
||||
matchedFiles.add(relPath);
|
||||
} else if (outputMode == "count") {
|
||||
contentLines.add("$relPath:$fileMatches");
|
||||
totalMatches += fileMatches;
|
||||
} else {
|
||||
// content mode
|
||||
final fileLines = content.split("\n");
|
||||
for (var i = 0; i < fileLines.length; i++) {
|
||||
if (regex.hasMatch(fileLines[i])) {
|
||||
final prefix = showLineNumbers ? "$relPath:${i + 1}:" : "$relPath:";
|
||||
contentLines.add("$prefix${fileLines[i]}");
|
||||
}
|
||||
}
|
||||
matchedFiles.add(relPath);
|
||||
await _searchFile(
|
||||
file: entity,
|
||||
regex: regex,
|
||||
glob: glob,
|
||||
outputMode: outputMode,
|
||||
showLineNumbers: showLineNumbers,
|
||||
baseDir: baseDir,
|
||||
matchedFiles: matchedFiles,
|
||||
contentLines: contentLines,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +231,65 @@ class GrepTool extends BaseTool {
|
||||
return _formatResults(contentLines, outputMode, headLimit, offset);
|
||||
}
|
||||
|
||||
Future<void> _searchFile({
|
||||
required File file,
|
||||
required RegExp regex,
|
||||
required String? glob,
|
||||
required String outputMode,
|
||||
required bool showLineNumbers,
|
||||
required String baseDir,
|
||||
required List<String> matchedFiles,
|
||||
required List<String> contentLines,
|
||||
}) async {
|
||||
final parts = file.path.split("/");
|
||||
if (parts.any((p) => _vcsSkip.contains(p))) return;
|
||||
|
||||
if (glob != null) {
|
||||
final filename = file.path.split("/").last;
|
||||
if (!_simpleGlobMatch(glob, filename)) return;
|
||||
}
|
||||
|
||||
String content;
|
||||
try {
|
||||
content = await file.readAsString(encoding: utf8);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileMatches = regex.allMatches(content).length;
|
||||
if (fileMatches == 0) return;
|
||||
|
||||
final relPath = _displayPath(file.path, baseDir);
|
||||
|
||||
if (outputMode == "files_with_matches") {
|
||||
matchedFiles.add(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputMode == "count") {
|
||||
contentLines.add("$relPath:$fileMatches");
|
||||
return;
|
||||
}
|
||||
|
||||
final fileLines = content.split("\n");
|
||||
for (var i = 0; i < fileLines.length; i++) {
|
||||
if (regex.hasMatch(fileLines[i])) {
|
||||
final prefix = showLineNumbers ? "$relPath:${i + 1}:" : "$relPath:";
|
||||
contentLines.add("$prefix${fileLines[i]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _displayPath(String filePath, String baseDir) {
|
||||
if (filePath == baseDir) {
|
||||
return filePath.split("/").last;
|
||||
}
|
||||
if (filePath.startsWith("$baseDir/")) {
|
||||
return filePath.substring(baseDir.length + 1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
String _formatResults(List<String> lines, String outputMode, int? headLimit, int offset) {
|
||||
// apply offset + head_limit
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'base_tool.dart';
|
||||
|
||||
/// Tool for interacting with Model Context Protocol (MCP) servers
|
||||
class McpTool extends BaseTool {
|
||||
@override
|
||||
final String name = 'MCP';
|
||||
|
||||
@override
|
||||
final String description =
|
||||
'Interact with Model Context Protocol (MCP) servers and resources';
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final action = input['action'] as String? ?? 'list';
|
||||
final serverName = input['server_name'] as String?;
|
||||
final resourceUri = input['resource_uri'] as String?;
|
||||
final command = input['command'] as String?;
|
||||
|
||||
switch (action.toLowerCase()) {
|
||||
case 'list':
|
||||
return _listServers();
|
||||
case 'resources':
|
||||
if (serverName == null) {
|
||||
return 'Error: server_name is required for resources action';
|
||||
}
|
||||
return await _listResources(serverName);
|
||||
case 'read':
|
||||
if (serverName == null || resourceUri == null) {
|
||||
return 'Error: server_name and resource_uri are required for read action';
|
||||
}
|
||||
return await _readResource(serverName, resourceUri);
|
||||
case 'connect':
|
||||
if (serverName == null || command == null) {
|
||||
return 'Error: server_name and command are required for connect action';
|
||||
}
|
||||
return await _connectServer(serverName, command);
|
||||
case 'disconnect':
|
||||
if (serverName == null) {
|
||||
return 'Error: server_name is required for disconnect action';
|
||||
}
|
||||
return await _disconnectServer(serverName);
|
||||
case 'info':
|
||||
if (serverName == null) {
|
||||
return 'Error: server_name is required for info action';
|
||||
}
|
||||
return await _serverInfo(serverName);
|
||||
default:
|
||||
return 'Error: Unknown action "$action". Available actions: list, resources, read, connect, disconnect, info';
|
||||
}
|
||||
}
|
||||
|
||||
String _listServers() {
|
||||
// In a real implementation, this would read from config
|
||||
final servers = <Map<String, dynamic>>[
|
||||
{
|
||||
'name': 'filesystem',
|
||||
'status': 'connected',
|
||||
'type': 'builtin',
|
||||
'description': 'Access to local filesystem',
|
||||
},
|
||||
{
|
||||
'name': 'git',
|
||||
'status': 'available',
|
||||
'type': 'builtin',
|
||||
'description': 'Git repository operations',
|
||||
},
|
||||
{
|
||||
'name': 'clock',
|
||||
'status': 'connected',
|
||||
'type': 'example',
|
||||
'description': 'Current time and date information',
|
||||
},
|
||||
];
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('MCP Servers (${servers.length}):');
|
||||
buffer.writeln('─' * 60);
|
||||
|
||||
for (final server in servers) {
|
||||
final status = server['status'] as String;
|
||||
final statusSymbol = status == 'connected' ? '●' : '○';
|
||||
buffer.write('$statusSymbol ${server['name']}');
|
||||
buffer.write(' (${server['type']})');
|
||||
buffer.writeln(' - ${server['description']}');
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
buffer.writeln('Use MCP:resources server_name="filesystem" to list available resources');
|
||||
buffer.writeln('Use MCP:connect server_name="new-server" command="npx @modelcontextprotocol/server-filesystem" to add a server');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _listResources(String serverName) async {
|
||||
// Mock resources based on server name
|
||||
final resources = <Map<String, String>>[];
|
||||
|
||||
switch (serverName.toLowerCase()) {
|
||||
case 'filesystem':
|
||||
resources.addAll([
|
||||
{'uri': 'file:///README.md', 'name': 'README.md', 'type': 'file'},
|
||||
{'uri': 'file:///lib/', 'name': 'lib directory', 'type': 'directory'},
|
||||
{'uri': 'file:///pubspec.yaml', 'name': 'pubspec.yaml', 'type': 'file'},
|
||||
]);
|
||||
break;
|
||||
case 'git':
|
||||
resources.addAll([
|
||||
{'uri': 'git:///status', 'name': 'Git Status', 'type': 'status'},
|
||||
{'uri': 'git:///log', 'name': 'Git Log', 'type': 'log'},
|
||||
{'uri': 'git:///diff', 'name': 'Git Diff', 'type': 'diff'},
|
||||
]);
|
||||
break;
|
||||
case 'clock':
|
||||
resources.addAll([
|
||||
{'uri': 'clock:///now', 'name': 'Current Time', 'type': 'time'},
|
||||
{'uri': 'clock:///date', 'name': 'Current Date', 'type': 'date'},
|
||||
{'uri': 'clock:///timezone', 'name': 'Timezone Info', 'type': 'timezone'},
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
return 'Error: Server "$serverName" not found or has no resources';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Resources for $serverName (${resources.length}):');
|
||||
buffer.writeln('─' * 60);
|
||||
|
||||
for (final resource in resources) {
|
||||
buffer.write('${resource['uri']}');
|
||||
buffer.write(' (${resource['type']})');
|
||||
buffer.writeln(' - ${resource['name']}');
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
buffer.writeln('Read a resource with: MCP:read server_name="$serverName" resource_uri="${resources.first['uri']}"');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _readResource(String serverName, String resourceUri) async {
|
||||
// Mock resource content based on URI
|
||||
final now = DateTime.now();
|
||||
|
||||
String content;
|
||||
switch (resourceUri) {
|
||||
case 'file:///README.md':
|
||||
content = '# Project README\n\nThis is a sample README file.';
|
||||
break;
|
||||
case 'file:///pubspec.yaml':
|
||||
content = 'name: clawd_code\ndescription: Claude Code Dart CLI\nversion: 1.0.0';
|
||||
break;
|
||||
case 'clock:///now':
|
||||
content = now.toIso8601String();
|
||||
break;
|
||||
case 'clock:///date':
|
||||
content = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
break;
|
||||
case 'git:///status':
|
||||
content = 'On branch main\nnothing to commit, working tree clean';
|
||||
break;
|
||||
default:
|
||||
if (resourceUri.startsWith('file:///')) {
|
||||
final path = resourceUri.substring(7);
|
||||
content = 'File content for $path\n\nThis is simulated MCP file content.';
|
||||
} else {
|
||||
content = 'Resource content for $resourceUri\n\nThis is simulated MCP resource data.';
|
||||
}
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Resource: $resourceUri');
|
||||
buffer.writeln('Server: $serverName');
|
||||
buffer.writeln('─' * 60);
|
||||
buffer.writeln(content);
|
||||
buffer.writeln('─' * 60);
|
||||
buffer.writeln();
|
||||
buffer.writeln('Note: This is simulated MCP resource data. In a real implementation,');
|
||||
buffer.writeln('this would connect to an actual MCP server.');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _connectServer(String serverName, String command) async {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Connecting MCP server: $serverName');
|
||||
buffer.writeln('Command: $command');
|
||||
buffer.writeln();
|
||||
buffer.writeln('In a real implementation, this would:');
|
||||
buffer.writeln('1. Start the MCP server process');
|
||||
buffer.writeln('2. Establish WebSocket connection');
|
||||
buffer.writeln('3. Initialize protocol handshake');
|
||||
buffer.writeln('4. Load available tools and resources');
|
||||
buffer.writeln();
|
||||
buffer.writeln('Simulated server connected successfully.');
|
||||
buffer.writeln();
|
||||
buffer.writeln('Use MCP:resources server_name="$serverName" to see available resources');
|
||||
buffer.writeln('Use MCP:info server_name="$serverName" to see server details');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _disconnectServer(String serverName) async {
|
||||
return '''Disconnecting MCP server: $serverName
|
||||
|
||||
In a real implementation, this would:
|
||||
1. Send shutdown signal to server
|
||||
2. Close WebSocket connection
|
||||
3. Clean up resources
|
||||
|
||||
Simulated server disconnected successfully.''';
|
||||
}
|
||||
|
||||
Future<String> _serverInfo(String serverName) async {
|
||||
final info = <String, dynamic>{
|
||||
'name': serverName,
|
||||
'protocol': 'MCP 2024-11-05',
|
||||
'version': '1.0.0',
|
||||
'capabilities': ['resources', 'tools', 'prompts'],
|
||||
'status': 'connected',
|
||||
'uptime': '5m 23s',
|
||||
'resources_count': 3,
|
||||
'tools_count': 2,
|
||||
};
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('MCP Server Info: $serverName');
|
||||
buffer.writeln('─' * 40);
|
||||
|
||||
for (final entry in info.entries) {
|
||||
if (entry.value is List) {
|
||||
buffer.writeln('${entry.key}: ${(entry.value as List).join(', ')}');
|
||||
} else {
|
||||
buffer.writeln('${entry.key}: ${entry.value}');
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import "base_tool.dart";
|
||||
|
||||
/// A basic agent tool for demonstration and simple agent operations
|
||||
class SimpleAgentTool extends BaseTool {
|
||||
@override
|
||||
final String name = "SimpleAgent";
|
||||
|
||||
@override
|
||||
final String description = "A basic agent tool for demonstration and simple agent management";
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final action = input["action"] as String? ?? "info";
|
||||
|
||||
switch (action.toLowerCase()) {
|
||||
case "info":
|
||||
return _agentInfo();
|
||||
case "list":
|
||||
return _listAgents();
|
||||
case "status":
|
||||
return _agentStatus();
|
||||
case "create":
|
||||
final agentName = input["name"] as String? ?? "demo_agent";
|
||||
return _createAgent(agentName);
|
||||
default:
|
||||
return 'Unknown action: $action. Available actions: info, list, status, create';
|
||||
}
|
||||
}
|
||||
|
||||
String _agentInfo() {
|
||||
return '''
|
||||
Simple Agent Tool Info:
|
||||
-----------------------
|
||||
This is a demonstration agent tool for the Dart CLI migration.
|
||||
In a full implementation, this would manage actual AI agents.
|
||||
|
||||
Features:
|
||||
- Basic agent lifecycle management
|
||||
- Agent status tracking
|
||||
- Simple agent creation
|
||||
|
||||
Note: This is a placeholder implementation for migration testing.
|
||||
''';
|
||||
}
|
||||
|
||||
String _listAgents() {
|
||||
return '''
|
||||
Available Agents (demo):
|
||||
-----------------------
|
||||
1. demo_agent_1 (status: idle, type: general-purpose)
|
||||
2. demo_agent_2 (status: busy, type: researcher)
|
||||
3. demo_agent_3 (status: idle, type: tester)
|
||||
|
||||
Total: 3 demo agents
|
||||
|
||||
Note: These are placeholder agents for demonstration.
|
||||
''';
|
||||
}
|
||||
|
||||
String _agentStatus() {
|
||||
return '''
|
||||
Agent Status Summary:
|
||||
--------------------
|
||||
Active agents: 1
|
||||
Idle agents: 2
|
||||
Total agents: 3
|
||||
|
||||
Last activity: ${DateTime.now().subtract(const Duration(minutes: 5))}
|
||||
|
||||
Note: This is demo status data.
|
||||
''';
|
||||
}
|
||||
|
||||
String _createAgent(String agentName) {
|
||||
final agentId = '${agentName}_${DateTime.now().millisecondsSinceEpoch ~/ 1000}';
|
||||
return '''
|
||||
Created new agent: $agentId
|
||||
----------------------------
|
||||
Name: $agentName
|
||||
ID: $agentId
|
||||
Status: initialized
|
||||
Type: general-purpose
|
||||
Created: ${DateTime.now()}
|
||||
|
||||
Note: This is a demo agent creation.
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import 'base_tool.dart';
|
||||
|
||||
/// Tool for managing and executing skills
|
||||
class SkillTool extends BaseTool {
|
||||
@override
|
||||
final String name = 'Skill';
|
||||
|
||||
@override
|
||||
final String description =
|
||||
'List, execute, and manage reusable prompt templates (skills)';
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final action = input['action'] as String? ?? 'list';
|
||||
final skillName = input['skill_name'] as String?;
|
||||
final content = input['content'] as String?;
|
||||
final params = input['params'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
switch (action.toLowerCase()) {
|
||||
case 'list':
|
||||
return _listSkills();
|
||||
case 'execute':
|
||||
if (skillName == null) {
|
||||
return 'Error: skill_name is required for execute action';
|
||||
}
|
||||
return await _executeSkill(skillName, params);
|
||||
case 'info':
|
||||
if (skillName == null) {
|
||||
return 'Error: skill_name is required for info action';
|
||||
}
|
||||
return await _getSkillInfo(skillName);
|
||||
case 'create':
|
||||
if (skillName == null || content == null) {
|
||||
return 'Error: skill_name and content are required for create action';
|
||||
}
|
||||
return await _createSkill(skillName, content);
|
||||
default:
|
||||
return 'Error: Unknown action "$action". Available actions: list, execute, info, create';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _listSkills() async {
|
||||
final skillsDir = await _getSkillsDirectory();
|
||||
final skills = <String>[];
|
||||
|
||||
if (await skillsDir.exists()) {
|
||||
final files = skillsDir.listSync();
|
||||
for (final file in files) {
|
||||
if (file is File && file.path.endsWith('.md')) {
|
||||
skills.add(path.basenameWithoutExtension(file.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skills.isEmpty) {
|
||||
return '''No skills found.
|
||||
|
||||
Skills are reusable prompt templates stored as .md files in:
|
||||
${skillsDir.path}
|
||||
|
||||
Create a skill with Skill:create skill_name="my-skill" content="# My Skill\\n\\nThis skill does something useful."''';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Available skills (${skills.length}):');
|
||||
buffer.writeln('─' * 40);
|
||||
|
||||
skills.sort();
|
||||
for (final skill in skills) {
|
||||
buffer.writeln('• $skill');
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
buffer.writeln('Execute a skill with: Skill:execute skill_name="$skills.first"');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _executeSkill(
|
||||
String skillName, Map<String, dynamic> params) async {
|
||||
final skillContent = await _loadSkill(skillName);
|
||||
if (skillContent.isEmpty) {
|
||||
return 'Error: Skill "$skillName" not found';
|
||||
}
|
||||
|
||||
// Replace template variables
|
||||
var result = skillContent;
|
||||
for (final entry in params.entries) {
|
||||
result = result.replaceAll('{{${entry.key}}}', entry.value.toString());
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Executing skill: $skillName');
|
||||
buffer.writeln('─' * 40);
|
||||
buffer.writeln();
|
||||
|
||||
// Parse skill metadata
|
||||
final lines = skillContent.split('\n');
|
||||
var inMetadata = false;
|
||||
var description = '';
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.startsWith('---')) {
|
||||
inMetadata = !inMetadata;
|
||||
continue;
|
||||
}
|
||||
if (inMetadata) {
|
||||
if (line.startsWith('description:')) {
|
||||
description = line.substring('description:'.length).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (description.isNotEmpty) {
|
||||
buffer.writeln('Description: $description');
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
buffer.writeln('Skill content:');
|
||||
buffer.writeln('─' * 40);
|
||||
buffer.writeln(result);
|
||||
buffer.writeln('─' * 40);
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _getSkillInfo(String skillName) async {
|
||||
final skillContent = await _loadSkill(skillName);
|
||||
if (skillContent.isEmpty) {
|
||||
return 'Error: Skill "$skillName" not found';
|
||||
}
|
||||
|
||||
final lines = skillContent.split('\n');
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Skill: $skillName');
|
||||
buffer.writeln('─' * 40);
|
||||
|
||||
var inMetadata = false;
|
||||
var hasMetadata = false;
|
||||
|
||||
for (final line in lines) {
|
||||
if (line.startsWith('---')) {
|
||||
if (!inMetadata) {
|
||||
hasMetadata = true;
|
||||
}
|
||||
inMetadata = !inMetadata;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inMetadata) {
|
||||
if (line.startsWith('description:')) {
|
||||
buffer.writeln('Description: ${line.substring('description:'.length).trim()}');
|
||||
} else if (line.startsWith('author:')) {
|
||||
buffer.writeln('Author: ${line.substring('author:'.length).trim()}');
|
||||
} else if (line.startsWith('version:')) {
|
||||
buffer.writeln('Version: ${line.substring('version:'.length).trim()}');
|
||||
} else if (line.startsWith('tags:')) {
|
||||
buffer.writeln('Tags: ${line.substring('tags:'.length).trim()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMetadata) {
|
||||
buffer.writeln('No metadata found.');
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
buffer.writeln('Preview (first 200 chars):');
|
||||
buffer.writeln('─' * 40);
|
||||
|
||||
final contentStart = skillContent.indexOf('---', 3) + 3;
|
||||
final preview = contentStart > 3
|
||||
? skillContent.substring(contentStart).trim()
|
||||
: skillContent.trim();
|
||||
|
||||
buffer.writeln(preview.length > 200 ? '${preview.substring(0, 200)}...' : preview);
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _createSkill(String skillName, String content) async {
|
||||
final skillsDir = await _getSkillsDirectory();
|
||||
await skillsDir.create(recursive: true);
|
||||
|
||||
final skillFile = File(path.join(skillsDir.path, '$skillName.md'));
|
||||
|
||||
// Add basic metadata if not present
|
||||
var skillContent = content;
|
||||
if (!content.startsWith('---')) {
|
||||
skillContent = '''---
|
||||
skill: $skillName
|
||||
created: ${DateTime.now().toIso8601String()}
|
||||
---
|
||||
|
||||
$content''';
|
||||
}
|
||||
|
||||
await skillFile.writeAsString(skillContent);
|
||||
|
||||
return '''Created skill: $skillName
|
||||
Location: ${skillFile.path}
|
||||
|
||||
Execute with: Skill:execute skill_name="$skillName"''';
|
||||
}
|
||||
|
||||
Future<Directory> _getSkillsDirectory() async {
|
||||
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
|
||||
final claudeDir = Directory(path.join(home, '.claude', 'skills'));
|
||||
return claudeDir;
|
||||
}
|
||||
|
||||
Future<String> _loadSkill(String skillName) async {
|
||||
final skillsDir = await _getSkillsDirectory();
|
||||
final skillFile = File(path.join(skillsDir.path, '$skillName.md'));
|
||||
|
||||
if (!await skillFile.exists()) {
|
||||
// Check project-local skills
|
||||
final localSkillsDir = Directory(path.join(Directory.current.path, '.claude', 'skills'));
|
||||
final localSkillFile = File(path.join(localSkillsDir.path, '$skillName.md'));
|
||||
if (await localSkillFile.exists()) {
|
||||
return await localSkillFile.readAsString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return await skillFile.readAsString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import 'base_tool.dart';
|
||||
|
||||
/// Tool for managing background tasks
|
||||
/// Tasks are persisted to ~/.clawd_code/tasks/ directory
|
||||
class TaskTool extends BaseTool {
|
||||
@override
|
||||
final String name = 'Task';
|
||||
|
||||
@override
|
||||
final String description =
|
||||
'Create, list, get, update, and stop background tasks';
|
||||
|
||||
// In-memory cache (loaded from disk)
|
||||
static final Map<String, Map<String, dynamic>> _tasks = {};
|
||||
static int _taskCounter = 1;
|
||||
static bool _initialized = false;
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
// Initialize task storage on first use
|
||||
if (!_initialized) {
|
||||
await _loadTasks();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
final action = input['action'] as String? ?? 'list';
|
||||
final taskId = input['task_id'] as String?;
|
||||
final taskName = input['name'] as String?;
|
||||
final command = input['command'] as String?;
|
||||
|
||||
switch (action.toLowerCase()) {
|
||||
case 'create':
|
||||
return await _createTask(command: command, name: taskName);
|
||||
case 'list':
|
||||
return _listTasks();
|
||||
case 'get':
|
||||
if (taskId == null) {
|
||||
return 'Error: task_id is required for get action';
|
||||
}
|
||||
return _getTask(taskId);
|
||||
case 'update':
|
||||
if (taskId == null) {
|
||||
return 'Error: task_id is required for update action';
|
||||
}
|
||||
final status = input['status'] as String?;
|
||||
final output = input['output'] as String?;
|
||||
return await _updateTask(taskId, status: status, output: output);
|
||||
case 'stop':
|
||||
if (taskId == null) {
|
||||
return 'Error: task_id is required for stop action';
|
||||
}
|
||||
return await _stopTask(taskId);
|
||||
case 'output':
|
||||
if (taskId == null) {
|
||||
return 'Error: task_id is required for output action';
|
||||
}
|
||||
return _getTaskOutput(taskId);
|
||||
default:
|
||||
return 'Error: Unknown action "$action". Available actions: create, list, get, update, stop, output';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _createTask({String? command, String? name}) async {
|
||||
final taskId = 'task_${_taskCounter++}';
|
||||
final now = DateTime.now().toUtc();
|
||||
|
||||
_tasks[taskId] = {
|
||||
'id': taskId,
|
||||
'name': name ?? 'Unnamed task',
|
||||
'command': command ?? '',
|
||||
'status': 'running',
|
||||
'created_at': now.toIso8601String(),
|
||||
'updated_at': now.toIso8601String(),
|
||||
'output': '',
|
||||
};
|
||||
|
||||
await _saveTasks();
|
||||
|
||||
return '''Created task: $taskId
|
||||
Name: ${name ?? 'Unnamed task'}
|
||||
Command: ${command ?? '(no command)'}
|
||||
Status: running
|
||||
Created: ${now.toLocal()}
|
||||
|
||||
Use /tasks to list tasks or Task:get task_id=$taskId to check status.''';
|
||||
}
|
||||
|
||||
String _listTasks() {
|
||||
if (_tasks.isEmpty) {
|
||||
return 'No tasks found. Create one with Task:create command="your command"';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Tasks (${_tasks.length}):');
|
||||
buffer.writeln('─' * 50);
|
||||
|
||||
for (final task in _tasks.values) {
|
||||
final id = task['id'] as String;
|
||||
final name = task['name'] as String;
|
||||
final status = task['status'] as String;
|
||||
final createdAt = task['created_at'] as String;
|
||||
final updatedAt = task['updated_at'] as String;
|
||||
|
||||
final created = DateTime.parse(createdAt).toLocal();
|
||||
final updated = DateTime.parse(updatedAt).toLocal();
|
||||
|
||||
buffer.writeln('$id: $name');
|
||||
buffer.writeln(' Status: $status');
|
||||
buffer.writeln(' Created: ${created.toString().substring(0, 16)}');
|
||||
buffer.writeln(' Updated: ${updated.toString().substring(0, 16)}');
|
||||
if (task['command'] != null && (task['command'] as String).isNotEmpty) {
|
||||
buffer.writeln(' Command: ${task['command']}');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _getTask(String taskId) {
|
||||
final task = _tasks[taskId];
|
||||
if (task == null) {
|
||||
return 'Error: Task "$taskId" not found';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Task: ${task['name']}');
|
||||
buffer.writeln('ID: $taskId');
|
||||
buffer.writeln('Status: ${task['status']}');
|
||||
buffer.writeln('Command: ${task['command']}');
|
||||
buffer.writeln('Created: ${DateTime.parse(task['created_at'] as String).toLocal()}');
|
||||
buffer.writeln('Updated: ${DateTime.parse(task['updated_at'] as String).toLocal()}');
|
||||
|
||||
final output = task['output'] as String;
|
||||
if (output.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
buffer.writeln('Output:');
|
||||
buffer.writeln(output);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<String> _updateTask(String taskId, {String? status, String? output}) async {
|
||||
final task = _tasks[taskId];
|
||||
if (task == null) {
|
||||
return 'Error: Task "$taskId" not found';
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
task['status'] = status;
|
||||
}
|
||||
if (output != null) {
|
||||
task['output'] = output;
|
||||
}
|
||||
task['updated_at'] = DateTime.now().toUtc().toIso8601String();
|
||||
|
||||
await _saveTasks();
|
||||
return 'Updated task $taskId';
|
||||
}
|
||||
|
||||
Future<String> _stopTask(String taskId) async {
|
||||
final task = _tasks[taskId];
|
||||
if (task == null) {
|
||||
return 'Error: Task "$taskId" not found';
|
||||
}
|
||||
|
||||
task['status'] = 'stopped';
|
||||
task['updated_at'] = DateTime.now().toUtc().toIso8601String();
|
||||
|
||||
await _saveTasks();
|
||||
return 'Stopped task $taskId';
|
||||
}
|
||||
|
||||
String _getTaskOutput(String taskId) {
|
||||
final task = _tasks[taskId];
|
||||
if (task == null) {
|
||||
return 'Error: Task "$taskId" not found';
|
||||
}
|
||||
|
||||
final output = task['output'] as String;
|
||||
if (output.isEmpty) {
|
||||
return 'No output recorded for task $taskId';
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Persistence: load tasks from disk
|
||||
Future<void> _loadTasks() async {
|
||||
try {
|
||||
final dir = _getTasksDirectory();
|
||||
if (!await dir.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
_tasks.clear();
|
||||
_taskCounter = 1;
|
||||
|
||||
final files = dir.listSync();
|
||||
for (final file in files) {
|
||||
if (file is! File || !file.path.endsWith('.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final content = await file.readAsString();
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
final taskId = json['id'] as String?;
|
||||
if (taskId != null) {
|
||||
_tasks[taskId] = json;
|
||||
// Update counter
|
||||
final match = RegExp(r'task_(\d+)').firstMatch(taskId);
|
||||
if (match != null) {
|
||||
final num = int.tryParse(match.group(1)!);
|
||||
if (num != null && num >= _taskCounter) {
|
||||
_taskCounter = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Skip malformed files
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// If loading fails, continue with empty
|
||||
}
|
||||
}
|
||||
|
||||
// Persistence: save tasks to disk
|
||||
Future<void> _saveTasks() async {
|
||||
try {
|
||||
final dir = _getTasksDirectory();
|
||||
await dir.create(recursive: true);
|
||||
|
||||
for (final entry in _tasks.entries) {
|
||||
final file = File(path.join(dir.path, '${entry.key}.json'));
|
||||
await file.writeAsString(jsonEncode(entry.value));
|
||||
}
|
||||
} catch (_) {
|
||||
// Silently fail - tasks stored in memory anyway
|
||||
}
|
||||
}
|
||||
|
||||
Directory _getTasksDirectory() {
|
||||
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
|
||||
return Directory(path.join(home, '.clawd_code', 'tasks'));
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,124 @@
|
||||
import "base_tool.dart";
|
||||
import "bash_tool.dart";
|
||||
import "glob_tool.dart";
|
||||
import "grep_tool.dart";
|
||||
import "execute_task_tool.dart";
|
||||
import "file_edit_tool.dart";
|
||||
import "file_read_tool.dart";
|
||||
import "file_write_tool.dart";
|
||||
import "file_edit_tool.dart";
|
||||
import "glob_tool.dart";
|
||||
import "grep_tool.dart";
|
||||
import "web_fetch_tool.dart";
|
||||
import "web_search_tool.dart";
|
||||
import "task_tool.dart";
|
||||
import "skill_tool.dart";
|
||||
import "mcp_tool.dart";
|
||||
import "../permissions/permission_manager.dart";
|
||||
import "../local_state.dart";
|
||||
import "../services/analytics_service.dart";
|
||||
import "../services/usage_tracker.dart";
|
||||
|
||||
|
||||
// registry that holds all available tools by name
|
||||
class ToolRegistry {
|
||||
final Map<String, BaseTool> _tools = {};
|
||||
PermissionManager? _permissionManager;
|
||||
|
||||
ToolRegistry() {
|
||||
_register(BashTool());
|
||||
_register(GlobTool());
|
||||
_register(GrepTool());
|
||||
_register(FileReadTool());
|
||||
_register(FileWriteTool());
|
||||
_register(FileEditTool());
|
||||
register(BashTool());
|
||||
register(ExecuteTaskTool());
|
||||
register(GlobTool());
|
||||
register(GrepTool());
|
||||
register(FileReadTool());
|
||||
register(FileEditTool());
|
||||
register(FileWriteTool());
|
||||
register(WebSearchTool());
|
||||
register(WebFetchTool());
|
||||
register(TaskTool());
|
||||
register(SkillTool());
|
||||
register(McpTool());
|
||||
}
|
||||
|
||||
void _register(BaseTool tool) {
|
||||
/// Set settings for permission management
|
||||
void setSettings(LocalSettings settings) {
|
||||
_permissionManager = PermissionManager(settings);
|
||||
}
|
||||
|
||||
final Map<String, BaseTool> _tools = <String, BaseTool>{};
|
||||
|
||||
List<BaseTool> get allTools => _tools.values.toList(growable: false);
|
||||
|
||||
List<String> get toolNames => _tools.keys.toList(growable: false);
|
||||
|
||||
void register(BaseTool tool) {
|
||||
_tools[tool.name] = tool;
|
||||
}
|
||||
|
||||
BaseTool? getTool(String toolName) {
|
||||
return _tools[toolName];
|
||||
}
|
||||
|
||||
BaseTool? getTool(String name) => _tools[name];
|
||||
|
||||
List<BaseTool> get allTools => _tools.values.toList();
|
||||
|
||||
List<String> get toolNames => _tools.keys.toList();
|
||||
|
||||
// execute a tool by name
|
||||
Future<String> execute(String toolName, Map<String, dynamic> input) async {
|
||||
final tool = _tools[toolName];
|
||||
final tool = getTool(toolName);
|
||||
if (tool == null) {
|
||||
return "Error: Unknown tool \"$toolName\". Available tools: ${toolNames.join(", ")}";
|
||||
return 'Error: Unknown tool "$toolName". Available tools: ${toolNames.join(", ")}';
|
||||
}
|
||||
|
||||
// Check permissions if manager is configured
|
||||
if (_permissionManager != null) {
|
||||
final permissionDecision = _permissionManager!.getPermissionDecision(toolName, input, null);
|
||||
|
||||
switch (permissionDecision) {
|
||||
case 'denied':
|
||||
return 'Permission denied: Tool "$toolName" is not allowed. '
|
||||
'Update permissions with /permissions or change permission mode.';
|
||||
case 'ask':
|
||||
final shouldRun = await _permissionManager!.getUserConfirmation(
|
||||
toolName,
|
||||
null,
|
||||
'Allow tool "$toolName" to run?',
|
||||
);
|
||||
if (!shouldRun) {
|
||||
return 'Permission denied: User declined to run tool "$toolName".';
|
||||
}
|
||||
break;
|
||||
case 'allowed':
|
||||
default:
|
||||
// Tool is allowed, continue
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Log tool execution
|
||||
await _logToolExecution(toolName, input);
|
||||
|
||||
return tool.execute(input);
|
||||
}
|
||||
|
||||
Future<void> _logToolExecution(String toolName, Map<String, dynamic> input) async {
|
||||
try {
|
||||
final analytics = AnalyticsService();
|
||||
await analytics.logTool(toolName, input: input);
|
||||
|
||||
final usage = UsageTracker();
|
||||
await usage.trackToolExecution(toolName);
|
||||
} catch (e) {
|
||||
// Silently fail - analytics is optional
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract tool arguments as a string for permission matching
|
||||
String _extractToolArgs(Map<String, dynamic> input) {
|
||||
final args = <String>[];
|
||||
|
||||
if (input.containsKey('input')) {
|
||||
args.add(input['input'].toString());
|
||||
}
|
||||
if (input.containsKey('command')) {
|
||||
args.add(input['command'].toString());
|
||||
}
|
||||
if (input.containsKey('path')) {
|
||||
args.add(input['path'].toString());
|
||||
}
|
||||
if (input.containsKey('pattern')) {
|
||||
args.add(input['pattern'].toString());
|
||||
}
|
||||
|
||||
return args.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:html/dom.dart" as dom;
|
||||
import "package:html/parser.dart" as html_parser;
|
||||
import "package:path/path.dart" as path;
|
||||
|
||||
import "../api/openrouter_client.dart";
|
||||
import "base_tool.dart";
|
||||
|
||||
const int _maxUrlLength = 2000;
|
||||
const int _maxFetchBytes = 10 * 1024 * 1024;
|
||||
const int _maxPromptContentChars = 100000;
|
||||
const int _maxRedirects = 10;
|
||||
const Duration _cacheTtl = Duration(minutes: 15);
|
||||
const int _maxCacheEntries = 64;
|
||||
|
||||
final List<_CacheKey> _cacheOrder = <_CacheKey>[];
|
||||
final Map<_CacheKey, _CacheEntry> _cache = <_CacheKey, _CacheEntry>{};
|
||||
|
||||
const Set<String> _preapprovedHosts = {
|
||||
"platform.claude.com",
|
||||
"code.claude.com",
|
||||
"modelcontextprotocol.io",
|
||||
"docs.python.org",
|
||||
"en.cppreference.com",
|
||||
"docs.oracle.com",
|
||||
"learn.microsoft.com",
|
||||
"developer.mozilla.org",
|
||||
"go.dev",
|
||||
"pkg.go.dev",
|
||||
"www.php.net",
|
||||
"docs.swift.org",
|
||||
"kotlinlang.org",
|
||||
"ruby-doc.org",
|
||||
"doc.rust-lang.org",
|
||||
"www.typescriptlang.org",
|
||||
"react.dev",
|
||||
"angular.io",
|
||||
"vuejs.org",
|
||||
"nextjs.org",
|
||||
"expressjs.com",
|
||||
"nodejs.org",
|
||||
"bun.sh",
|
||||
"getbootstrap.com",
|
||||
"tailwindcss.com",
|
||||
"redux.js.org",
|
||||
"webpack.js.org",
|
||||
"jestjs.io",
|
||||
"reactrouter.com",
|
||||
"docs.djangoproject.com",
|
||||
"flask.palletsprojects.com",
|
||||
"fastapi.tiangolo.com",
|
||||
"pandas.pydata.org",
|
||||
"numpy.org",
|
||||
"www.tensorflow.org",
|
||||
"pytorch.org",
|
||||
"scikit-learn.org",
|
||||
"matplotlib.org",
|
||||
"requests.readthedocs.io",
|
||||
"jupyter.org",
|
||||
"laravel.com",
|
||||
"symfony.com",
|
||||
"wordpress.org",
|
||||
"docs.spring.io",
|
||||
"hibernate.org",
|
||||
"tomcat.apache.org",
|
||||
"gradle.org",
|
||||
"maven.apache.org",
|
||||
"asp.net",
|
||||
"dotnet.microsoft.com",
|
||||
"nuget.org",
|
||||
"reactnative.dev",
|
||||
"docs.flutter.dev",
|
||||
"developer.apple.com",
|
||||
"developer.android.com",
|
||||
"keras.io",
|
||||
"spark.apache.org",
|
||||
"huggingface.co",
|
||||
"www.kaggle.com",
|
||||
"www.mongodb.com",
|
||||
"redis.io",
|
||||
"www.postgresql.org",
|
||||
"dev.mysql.com",
|
||||
"www.sqlite.org",
|
||||
"graphql.org",
|
||||
"prisma.io",
|
||||
"docs.aws.amazon.com",
|
||||
"cloud.google.com",
|
||||
"kubernetes.io",
|
||||
"www.docker.com",
|
||||
"www.terraform.io",
|
||||
"www.ansible.com",
|
||||
"docs.netlify.com",
|
||||
"devcenter.heroku.com",
|
||||
"cypress.io",
|
||||
"selenium.dev",
|
||||
"docs.unity.com",
|
||||
"docs.unrealengine.com",
|
||||
"git-scm.com",
|
||||
"nginx.org",
|
||||
"httpd.apache.org",
|
||||
};
|
||||
|
||||
const Map<String, List<String>> _preapprovedPathPrefixes = {
|
||||
"github.com": <String>["/anthropics"],
|
||||
"vercel.com": <String>["/docs"],
|
||||
};
|
||||
|
||||
class WebFetchTool extends BaseTool {
|
||||
@override
|
||||
final String name = "WebFetch";
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"Fetch content from a URL, convert it to readable markdown-like text, and answer a prompt about that page.";
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final rawUrl = requireString(input, "url").trim();
|
||||
final prompt = requireString(input, "prompt").trim();
|
||||
final apiKey = optionalString(input, "_api_key") ?? "";
|
||||
final model = optionalString(input, "_model") ?? "openrouter/auto";
|
||||
final permissionMode = optionalString(input, "_permission_mode") ?? "default";
|
||||
final allowRules = _readStringList(input["_allow_rules"]);
|
||||
final askRules = _readStringList(input["_ask_rules"]);
|
||||
final denyRules = _readStringList(input["_deny_rules"]);
|
||||
|
||||
if (prompt.isEmpty) {
|
||||
throw ArgumentError("prompt must not be empty");
|
||||
}
|
||||
if (apiKey.isEmpty) {
|
||||
throw StateError("WebFetch requires an OpenRouter API key");
|
||||
}
|
||||
|
||||
final url = _normalizeUrl(rawUrl);
|
||||
final uri = Uri.parse(url);
|
||||
_enforcePermissions(
|
||||
uri: uri,
|
||||
permissionMode: permissionMode,
|
||||
allowRules: allowRules,
|
||||
askRules: askRules,
|
||||
denyRules: denyRules,
|
||||
);
|
||||
|
||||
final isPreapproved = _isPreapprovedUrl(url);
|
||||
final startTime = DateTime.now();
|
||||
final fetched = await _fetchUrl(url);
|
||||
final durationMs = DateTime.now().difference(startTime).inMilliseconds;
|
||||
|
||||
if (fetched.isRedirectNotice) {
|
||||
return _formatOutput(
|
||||
fetched: fetched,
|
||||
durationMs: durationMs,
|
||||
result: fetched.content,
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _summarizeFetchedContent(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
fetched: fetched,
|
||||
prompt: prompt,
|
||||
isPreapproved: isPreapproved,
|
||||
);
|
||||
|
||||
return _formatOutput(
|
||||
fetched: fetched,
|
||||
durationMs: durationMs,
|
||||
result: result,
|
||||
);
|
||||
}
|
||||
|
||||
String _normalizeUrl(String rawUrl) {
|
||||
if (rawUrl.isEmpty || rawUrl.length > _maxUrlLength) {
|
||||
throw ArgumentError("Invalid URL");
|
||||
}
|
||||
|
||||
Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(rawUrl);
|
||||
} catch (_) {
|
||||
throw ArgumentError("Invalid URL: $rawUrl");
|
||||
}
|
||||
|
||||
if (!uri.hasScheme) {
|
||||
throw ArgumentError("URL must include a scheme");
|
||||
}
|
||||
if (uri.userInfo.isNotEmpty || uri.host.isEmpty) {
|
||||
throw ArgumentError("Invalid URL");
|
||||
}
|
||||
if (uri.scheme == "http") {
|
||||
uri = uri.replace(scheme: "https");
|
||||
}
|
||||
if (uri.scheme != "https") {
|
||||
throw ArgumentError("Only https URLs are supported");
|
||||
}
|
||||
|
||||
final host = uri.host.toLowerCase();
|
||||
if (_isLocalOrPrivateHost(host)) {
|
||||
throw ArgumentError("Fetching local or private network URLs is not allowed");
|
||||
}
|
||||
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
void _enforcePermissions({
|
||||
required Uri uri,
|
||||
required String permissionMode,
|
||||
required List<String> allowRules,
|
||||
required List<String> askRules,
|
||||
required List<String> denyRules,
|
||||
}) {
|
||||
final bypassModes = <String>{"bypassPermissions", "dontAsk"};
|
||||
if (bypassModes.contains(permissionMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final domainRule = "domain:${uri.host.toLowerCase()}";
|
||||
final matchingDeny = denyRules.where((rule) => _matchesRule(rule, uri)).toList();
|
||||
if (matchingDeny.isNotEmpty) {
|
||||
throw StateError("WebFetch denied access to $domainRule.");
|
||||
}
|
||||
|
||||
final matchingAllow = allowRules.where((rule) => _matchesRule(rule, uri)).toList();
|
||||
if (matchingAllow.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final matchingAsk = askRules.where((rule) => _matchesRule(rule, uri)).toList();
|
||||
if (matchingAsk.isNotEmpty) {
|
||||
throw StateError(
|
||||
"WebFetch requires permission for $domainRule. Add an allow rule to proceed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _matchesRule(String rawRule, Uri uri) {
|
||||
var rule = rawRule.trim();
|
||||
if (rule.startsWith("WebFetch(") && rule.endsWith(")")) {
|
||||
rule = rule.substring("WebFetch(".length, rule.length - 1);
|
||||
}
|
||||
if (!rule.startsWith("domain:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final pattern = rule.substring("domain:".length).toLowerCase();
|
||||
final host = uri.host.toLowerCase();
|
||||
if (pattern.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (pattern == host) {
|
||||
return true;
|
||||
}
|
||||
if (pattern.startsWith("*.")) {
|
||||
final suffix = pattern.substring(1);
|
||||
return host.endsWith(suffix);
|
||||
}
|
||||
if (pattern.endsWith(".*")) {
|
||||
final prefix = pattern.substring(0, pattern.length - 1);
|
||||
return host.startsWith(prefix);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<_FetchedContent> _fetchUrl(String originalUrl) async {
|
||||
_pruneCache();
|
||||
final cacheKey = _CacheKey(originalUrl);
|
||||
final cached = _cache[cacheKey];
|
||||
if (cached != null && DateTime.now().difference(cached.fetchedAt) < _cacheTtl) {
|
||||
_touchCacheEntry(cacheKey);
|
||||
return cached.content;
|
||||
}
|
||||
|
||||
final httpClient = HttpClient()..connectionTimeout = const Duration(seconds: 60);
|
||||
try {
|
||||
var currentUrl = Uri.parse(originalUrl);
|
||||
final originalComparableHost = _stripWww(currentUrl.host);
|
||||
|
||||
for (var redirectCount = 0; redirectCount <= _maxRedirects; redirectCount++) {
|
||||
final request = await httpClient.getUrl(currentUrl);
|
||||
request.headers.set("Accept", "text/markdown, text/html, text/plain, */*");
|
||||
request.headers.set("User-Agent", "clawd_code/0.1.0 (WebFetch)");
|
||||
|
||||
final response = await request.close().timeout(const Duration(seconds: 60));
|
||||
final statusCode = response.statusCode;
|
||||
final statusText = response.reasonPhrase;
|
||||
final location = response.headers.value(HttpHeaders.locationHeader);
|
||||
|
||||
if (_isRedirect(statusCode) && location != null) {
|
||||
final redirectUrl = currentUrl.resolve(location);
|
||||
if (_stripWww(redirectUrl.host) != originalComparableHost) {
|
||||
final redirectNotice = _FetchedContent(
|
||||
finalUrl: currentUrl.toString(),
|
||||
statusCode: statusCode,
|
||||
reasonPhrase: statusText,
|
||||
bytes: 0,
|
||||
contentType: "text/plain",
|
||||
content:
|
||||
"REDIRECT DETECTED: The URL redirects to a different host.\n\n"
|
||||
"Original URL: $currentUrl\n"
|
||||
"Redirect URL: $redirectUrl\n"
|
||||
"Status: $statusCode $statusText\n\n"
|
||||
"To complete your request, use WebFetch again with these parameters:\n"
|
||||
"- url: \"$redirectUrl\"",
|
||||
isRedirectNotice: true,
|
||||
);
|
||||
_storeCacheEntry(cacheKey, redirectNotice);
|
||||
return redirectNotice;
|
||||
}
|
||||
|
||||
currentUrl = redirectUrl;
|
||||
continue;
|
||||
}
|
||||
|
||||
final bytes = await _readResponseBytes(response);
|
||||
final contentType =
|
||||
response.headers.contentType?.mimeType ?? "application/octet-stream";
|
||||
final isBinary = _looksBinary(contentType, bytes);
|
||||
final persistedBinary = isBinary
|
||||
? await _persistBinaryContent(bytes, contentType)
|
||||
: null;
|
||||
final decodedText = _decodeBody(bytes, isBinary: isBinary);
|
||||
final readableContent = _extractReadableContent(
|
||||
decodedText,
|
||||
contentType: contentType,
|
||||
url: currentUrl.toString(),
|
||||
);
|
||||
|
||||
final fetched = _FetchedContent(
|
||||
finalUrl: currentUrl.toString(),
|
||||
statusCode: statusCode,
|
||||
reasonPhrase: statusText,
|
||||
bytes: bytes.length,
|
||||
contentType: contentType,
|
||||
content: readableContent,
|
||||
persistedBinaryPath: persistedBinary?.path,
|
||||
persistedBinarySize: persistedBinary?.size,
|
||||
);
|
||||
_storeCacheEntry(cacheKey, fetched);
|
||||
return fetched;
|
||||
}
|
||||
|
||||
throw StateError("Too many redirects");
|
||||
} finally {
|
||||
httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> _readResponseBytes(HttpClientResponse response) async {
|
||||
final builder = BytesBuilder(copy: false);
|
||||
await for (final chunk in response) {
|
||||
builder.add(chunk);
|
||||
if (builder.length > _maxFetchBytes) {
|
||||
throw StateError("Response exceeded ${_maxFetchBytes} bytes");
|
||||
}
|
||||
}
|
||||
return builder.takeBytes();
|
||||
}
|
||||
|
||||
String _decodeBody(List<int> bytes, {required bool isBinary}) {
|
||||
if (isBinary) {
|
||||
return latin1.decode(bytes, allowInvalid: true);
|
||||
}
|
||||
|
||||
try {
|
||||
return utf8.decode(bytes);
|
||||
} catch (_) {
|
||||
return latin1.decode(bytes, allowInvalid: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<_PersistedBinary?> _persistBinaryContent(
|
||||
List<int> bytes,
|
||||
String contentType,
|
||||
) async {
|
||||
try {
|
||||
final extension = _extensionForMimeType(contentType);
|
||||
final fileName =
|
||||
"webfetch-${DateTime.now().millisecondsSinceEpoch}-${_randomSuffix()}$extension";
|
||||
final file = File(path.join(Directory.systemTemp.path, fileName));
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return _PersistedBinary(path: file.path, size: bytes.length);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _extractReadableContent(
|
||||
String rawContent, {
|
||||
required String contentType,
|
||||
required String url,
|
||||
}) {
|
||||
if (contentType.contains("markdown") || contentType.contains("plain")) {
|
||||
return _truncateContent(rawContent.trim());
|
||||
}
|
||||
|
||||
final document = html_parser.parse(rawContent);
|
||||
document.querySelectorAll("script,style,noscript,svg,iframe").forEach((node) {
|
||||
node.remove();
|
||||
});
|
||||
|
||||
final title = document.querySelector("title")?.text.trim();
|
||||
final description = document
|
||||
.querySelector('meta[name="description"], meta[property="og:description"]')
|
||||
?.attributes["content"]
|
||||
?.trim();
|
||||
|
||||
final root =
|
||||
document.querySelector("article") ??
|
||||
document.querySelector("main") ??
|
||||
document.body ??
|
||||
document.documentElement;
|
||||
|
||||
if (root == null) {
|
||||
throw StateError("No readable content found at $url");
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
if (title != null && title.isNotEmpty) {
|
||||
buffer.writeln("# $title");
|
||||
buffer.writeln();
|
||||
}
|
||||
if (description != null && description.isNotEmpty) {
|
||||
buffer.writeln(description);
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
for (final node in root.nodes) {
|
||||
_writeNode(node, buffer, listDepth: 0, inPre: false);
|
||||
}
|
||||
|
||||
var result = buffer.toString();
|
||||
result = result.replaceAll(RegExp(r"\n{3,}"), "\n\n").trim();
|
||||
result = _decodeHtmlEntities(result);
|
||||
if (result.isEmpty) {
|
||||
throw StateError("No readable content found at $url");
|
||||
}
|
||||
|
||||
return _truncateContent(result);
|
||||
}
|
||||
|
||||
void _writeNode(
|
||||
dom.Node node,
|
||||
StringBuffer buffer, {
|
||||
required int listDepth,
|
||||
required bool inPre,
|
||||
}) {
|
||||
if (node is dom.Text) {
|
||||
final text = inPre
|
||||
? node.text
|
||||
: node.text.replaceAll(RegExp(r"\s+"), " ");
|
||||
if (text.trim().isNotEmpty) {
|
||||
buffer.write(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (node is! dom.Element) {
|
||||
return;
|
||||
}
|
||||
|
||||
final tag = node.localName?.toLowerCase() ?? "";
|
||||
switch (tag) {
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6":
|
||||
final level = int.tryParse(tag.substring(1)) ?? 1;
|
||||
buffer
|
||||
..writeln()
|
||||
..write("${"#" * level} ${node.text.trim()}")
|
||||
..writeln()
|
||||
..writeln();
|
||||
return;
|
||||
case "p":
|
||||
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
|
||||
buffer.writeln();
|
||||
buffer.writeln();
|
||||
return;
|
||||
case "br":
|
||||
buffer.writeln();
|
||||
return;
|
||||
case "pre":
|
||||
final code = node.text.trimRight();
|
||||
if (code.isNotEmpty) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln("```")
|
||||
..writeln(code)
|
||||
..writeln("```")
|
||||
..writeln();
|
||||
}
|
||||
return;
|
||||
case "code":
|
||||
final code = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
|
||||
if (code.isNotEmpty) {
|
||||
buffer.write("`$code`");
|
||||
}
|
||||
return;
|
||||
case "ul":
|
||||
case "ol":
|
||||
buffer.writeln();
|
||||
var index = 1;
|
||||
for (final child in node.children.where((child) => child.localName == "li")) {
|
||||
final prefix = tag == "ol" ? "${index++}." : "-";
|
||||
buffer.write("${" " * listDepth}$prefix ");
|
||||
_writeChildren(child, buffer, listDepth: listDepth + 1, inPre: false);
|
||||
buffer.writeln();
|
||||
}
|
||||
buffer.writeln();
|
||||
return;
|
||||
case "li":
|
||||
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
|
||||
return;
|
||||
case "a":
|
||||
final label = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
|
||||
final href = node.attributes["href"]?.trim();
|
||||
if (label.isNotEmpty && href != null && href.isNotEmpty) {
|
||||
buffer.write("[$label]($href)");
|
||||
} else {
|
||||
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
|
||||
}
|
||||
return;
|
||||
case "blockquote":
|
||||
final quote = node.text.trim();
|
||||
if (quote.isNotEmpty) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln("> ${quote.replaceAll("\n", "\n> ")}")
|
||||
..writeln();
|
||||
}
|
||||
return;
|
||||
case "table":
|
||||
final tableText = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
|
||||
if (tableText.isNotEmpty) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln(tableText)
|
||||
..writeln();
|
||||
}
|
||||
return;
|
||||
case "hr":
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln("---")
|
||||
..writeln();
|
||||
return;
|
||||
default:
|
||||
final blockTags = <String>{
|
||||
"article",
|
||||
"section",
|
||||
"main",
|
||||
"div",
|
||||
"header",
|
||||
"footer",
|
||||
"nav",
|
||||
"aside",
|
||||
};
|
||||
final wasBlock = blockTags.contains(tag);
|
||||
if (wasBlock) {
|
||||
buffer.writeln();
|
||||
}
|
||||
_writeChildren(node, buffer, listDepth: listDepth, inPre: inPre);
|
||||
if (wasBlock) {
|
||||
buffer.writeln();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _writeChildren(
|
||||
dom.Element element,
|
||||
StringBuffer buffer, {
|
||||
required int listDepth,
|
||||
required bool inPre,
|
||||
}) {
|
||||
for (final child in element.nodes) {
|
||||
_writeNode(child, buffer, listDepth: listDepth, inPre: inPre);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _summarizeFetchedContent({
|
||||
required String apiKey,
|
||||
required String model,
|
||||
required _FetchedContent fetched,
|
||||
required String prompt,
|
||||
required bool isPreapproved,
|
||||
}) async {
|
||||
if (isPreapproved &&
|
||||
fetched.contentType.contains("markdown") &&
|
||||
fetched.content.length <= _maxPromptContentChars) {
|
||||
return fetched.content;
|
||||
}
|
||||
|
||||
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
|
||||
try {
|
||||
final response = await client.createMessage(
|
||||
model: model,
|
||||
maxTokens: 2048,
|
||||
messages: <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
"role": "system",
|
||||
"content": isPreapproved
|
||||
? "Provide a concise response based on the fetched content. Include relevant details and code examples when present."
|
||||
: "Provide a concise response based only on the fetched content. Use short quotes only when necessary.",
|
||||
},
|
||||
<String, dynamic>{
|
||||
"role": "user",
|
||||
"content":
|
||||
"URL: ${fetched.finalUrl}\n"
|
||||
"Content-Type: ${fetched.contentType}\n\n"
|
||||
"Web page content:\n---\n${fetched.content}\n---\n\n"
|
||||
"$prompt",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
final parts = <String>[];
|
||||
for (final block in response.content) {
|
||||
if (block is Map<String, dynamic> && block["type"] == "text") {
|
||||
final text = block["text"];
|
||||
if (text is String && text.isNotEmpty) {
|
||||
parts.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final result = parts.join("\n").trim();
|
||||
return result.isEmpty ? "No response from model." : result;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
String _formatOutput({
|
||||
required _FetchedContent fetched,
|
||||
required int durationMs,
|
||||
required String result,
|
||||
}) {
|
||||
final lines = <String>[
|
||||
"URL: ${fetched.finalUrl}",
|
||||
"Status: ${fetched.statusCode} ${fetched.reasonPhrase}",
|
||||
"Bytes: ${fetched.bytes}",
|
||||
"Duration: ${_formatDuration(durationMs)}",
|
||||
];
|
||||
|
||||
if (fetched.persistedBinaryPath != null && fetched.persistedBinarySize != null) {
|
||||
lines.add(
|
||||
"Binary content saved: ${fetched.persistedBinaryPath} (${fetched.persistedBinarySize} bytes)",
|
||||
);
|
||||
}
|
||||
|
||||
lines
|
||||
..add("")
|
||||
..add(result.trim());
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
void _pruneCache() {
|
||||
final now = DateTime.now();
|
||||
_cacheOrder.removeWhere((key) {
|
||||
final entry = _cache[key];
|
||||
final expired = entry == null || now.difference(entry.fetchedAt) >= _cacheTtl;
|
||||
if (expired) {
|
||||
_cache.remove(key);
|
||||
}
|
||||
return expired;
|
||||
});
|
||||
}
|
||||
|
||||
void _storeCacheEntry(_CacheKey key, _FetchedContent content) {
|
||||
_cache[key] = _CacheEntry(content: content, fetchedAt: DateTime.now());
|
||||
_touchCacheEntry(key);
|
||||
while (_cacheOrder.length > _maxCacheEntries) {
|
||||
final removed = _cacheOrder.removeAt(0);
|
||||
_cache.remove(removed);
|
||||
}
|
||||
}
|
||||
|
||||
void _touchCacheEntry(_CacheKey key) {
|
||||
_cacheOrder.remove(key);
|
||||
_cacheOrder.add(key);
|
||||
}
|
||||
|
||||
bool _isPreapprovedUrl(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
final host = uri.host.toLowerCase();
|
||||
if (_preapprovedHosts.contains(host)) {
|
||||
return true;
|
||||
}
|
||||
final prefixes = _preapprovedPathPrefixes[host];
|
||||
if (prefixes == null) {
|
||||
return false;
|
||||
}
|
||||
final pathName = uri.path;
|
||||
return prefixes.any(
|
||||
(prefix) => pathName == prefix || pathName.startsWith("$prefix/"),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isLocalOrPrivateHost(String host) {
|
||||
final lower = host.toLowerCase();
|
||||
if (lower == "localhost" || !lower.contains(".")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.endsWith(".local")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final ipv4 = RegExp(r"^(\d{1,3}\.){3}\d{1,3}$");
|
||||
if (ipv4.hasMatch(lower)) {
|
||||
final parts = lower.split(".").map(int.parse).toList();
|
||||
if (parts.any((part) => part < 0 || part > 255)) {
|
||||
return true;
|
||||
}
|
||||
return parts[0] == 10 ||
|
||||
parts[0] == 127 ||
|
||||
(parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
||||
(parts[0] == 192 && parts[1] == 168) ||
|
||||
(parts[0] == 169 && parts[1] == 254);
|
||||
}
|
||||
|
||||
if (lower == "::1" || lower.startsWith("fc") || lower.startsWith("fd")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isRedirect(int statusCode) {
|
||||
return statusCode == 301 ||
|
||||
statusCode == 302 ||
|
||||
statusCode == 307 ||
|
||||
statusCode == 308;
|
||||
}
|
||||
|
||||
bool _looksBinary(String contentType, List<int> bytes) {
|
||||
if (contentType.startsWith("text/") ||
|
||||
contentType.contains("json") ||
|
||||
contentType.contains("xml") ||
|
||||
contentType.contains("javascript") ||
|
||||
contentType.contains("xhtml")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (final byte in bytes.take(256)) {
|
||||
if (byte == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String _truncateContent(String content) {
|
||||
if (content.length <= _maxPromptContentChars) {
|
||||
return content;
|
||||
}
|
||||
return "${content.substring(0, _maxPromptContentChars)}\n\n[Content truncated due to length...]";
|
||||
}
|
||||
|
||||
String _decodeHtmlEntities(String text) {
|
||||
return text
|
||||
.replaceAll(" ", " ")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", "\"")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
String _extensionForMimeType(String contentType) {
|
||||
if (contentType.contains("pdf")) return ".pdf";
|
||||
if (contentType.contains("zip")) return ".zip";
|
||||
if (contentType.contains("png")) return ".png";
|
||||
if (contentType.contains("jpeg")) return ".jpg";
|
||||
if (contentType.contains("gif")) return ".gif";
|
||||
if (contentType.contains("webp")) return ".webp";
|
||||
if (contentType.contains("json")) return ".json";
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
List<String> _readStringList(Object? value) {
|
||||
if (value is! List) {
|
||||
return const <String>[];
|
||||
}
|
||||
return value
|
||||
.whereType<String>()
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _randomSuffix() {
|
||||
final radix = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
|
||||
return radix.substring(radix.length - 6);
|
||||
}
|
||||
|
||||
String _stripWww(String host) => host.replaceFirst(RegExp(r"^www\."), "");
|
||||
|
||||
String _formatDuration(int durationMs) {
|
||||
if (durationMs < 1000) {
|
||||
return "${durationMs}ms";
|
||||
}
|
||||
return "${(durationMs / 1000).toStringAsFixed(1)}s";
|
||||
}
|
||||
}
|
||||
|
||||
class _FetchedContent {
|
||||
const _FetchedContent({
|
||||
required this.finalUrl,
|
||||
required this.statusCode,
|
||||
required this.reasonPhrase,
|
||||
required this.bytes,
|
||||
required this.contentType,
|
||||
required this.content,
|
||||
this.persistedBinaryPath,
|
||||
this.persistedBinarySize,
|
||||
this.isRedirectNotice = false,
|
||||
});
|
||||
|
||||
final String finalUrl;
|
||||
final int statusCode;
|
||||
final String reasonPhrase;
|
||||
final int bytes;
|
||||
final String contentType;
|
||||
final String content;
|
||||
final String? persistedBinaryPath;
|
||||
final int? persistedBinarySize;
|
||||
final bool isRedirectNotice;
|
||||
}
|
||||
|
||||
class _PersistedBinary {
|
||||
const _PersistedBinary({required this.path, required this.size});
|
||||
|
||||
final String path;
|
||||
final int size;
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
const _CacheEntry({required this.content, required this.fetchedAt});
|
||||
|
||||
final _FetchedContent content;
|
||||
final DateTime fetchedAt;
|
||||
}
|
||||
|
||||
class _CacheKey {
|
||||
const _CacheKey(this.url);
|
||||
|
||||
final String url;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || other is _CacheKey && other.url == url;
|
||||
|
||||
@override
|
||||
int get hashCode => url.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "../api/request_builder.dart";
|
||||
import "base_tool.dart";
|
||||
|
||||
class WebSearchTool extends BaseTool {
|
||||
@override
|
||||
final String name = "WebSearch";
|
||||
|
||||
@override
|
||||
final String description =
|
||||
"Search the web for current information. Supports optional domain allow/block filters and returns a cited summary.";
|
||||
|
||||
@override
|
||||
Future<String> execute(Map<String, dynamic> input) async {
|
||||
final query = requireString(input, "query").trim();
|
||||
final allowedDomains = _readStringList(input["allowed_domains"]);
|
||||
final blockedDomains = _readStringList(input["blocked_domains"]);
|
||||
final apiKey = optionalString(input, "_api_key") ?? "";
|
||||
final model = optionalString(input, "_model") ?? "openrouter/auto";
|
||||
|
||||
if (query.length < 2) {
|
||||
throw ArgumentError("query must be at least 2 characters");
|
||||
}
|
||||
if (allowedDomains.isNotEmpty && blockedDomains.isNotEmpty) {
|
||||
throw ArgumentError(
|
||||
"Cannot specify both allowed_domains and blocked_domains in the same request",
|
||||
);
|
||||
}
|
||||
if (apiKey.isEmpty) {
|
||||
throw StateError("Web search requires an OpenRouter API key");
|
||||
}
|
||||
|
||||
final startTime = DateTime.now();
|
||||
final response = await _performSearch(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
query: query,
|
||||
allowedDomains: allowedDomains,
|
||||
blockedDomains: blockedDomains,
|
||||
);
|
||||
final durationMs = DateTime.now().difference(startTime).inMilliseconds;
|
||||
|
||||
return _formatResult(
|
||||
query: query,
|
||||
response: response,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _performSearch({
|
||||
required String apiKey,
|
||||
required String model,
|
||||
required String query,
|
||||
required List<String> allowedDomains,
|
||||
required List<String> blockedDomains,
|
||||
}) async {
|
||||
final httpClient = HttpClient()..connectionTimeout = const Duration(seconds: 60);
|
||||
try {
|
||||
final request = await httpClient.openUrl(
|
||||
"POST",
|
||||
Uri.parse("https://openrouter.ai/api/v1/chat/completions"),
|
||||
);
|
||||
|
||||
final headers = HeaderBuilder();
|
||||
headers.addAuthHeader(apiKey);
|
||||
headers.addOpenRouterHeaders();
|
||||
for (final entry in headers.build().entries) {
|
||||
request.headers.set(entry.key, entry.value);
|
||||
}
|
||||
request.headers.contentType = ContentType.json;
|
||||
|
||||
final searchTool = <String, dynamic>{
|
||||
"type": "openrouter:web_search",
|
||||
"parameters": <String, dynamic>{
|
||||
"max_results": 5,
|
||||
"max_total_results": 20,
|
||||
"search_context_size": "medium",
|
||||
},
|
||||
};
|
||||
final parameters = searchTool["parameters"] as Map<String, dynamic>;
|
||||
if (allowedDomains.isNotEmpty) {
|
||||
parameters["allowed_domains"] = allowedDomains;
|
||||
}
|
||||
if (blockedDomains.isNotEmpty) {
|
||||
parameters["excluded_domains"] = blockedDomains;
|
||||
}
|
||||
|
||||
final requestBody = <String, dynamic>{
|
||||
"model": model,
|
||||
"max_tokens": 2048,
|
||||
"messages": <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
"role": "system",
|
||||
"content": _buildSearchPrompt(),
|
||||
},
|
||||
<String, dynamic>{
|
||||
"role": "user",
|
||||
"content": "Perform a web search for the query: $query",
|
||||
},
|
||||
],
|
||||
"tools": <Map<String, dynamic>>[searchTool],
|
||||
};
|
||||
|
||||
request.write(jsonEncode(requestBody));
|
||||
final response = await request.close();
|
||||
final responseBody = await response.transform(utf8.decoder).join();
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
throw StateError(
|
||||
"OpenRouter web search failed with HTTP ${response.statusCode}: $responseBody",
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(responseBody);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw StateError("Unexpected web search response format");
|
||||
}
|
||||
return decoded;
|
||||
} finally {
|
||||
httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
String _buildSearchPrompt() {
|
||||
final now = DateTime.now();
|
||||
final monthYear = "${_monthName(now.month)} ${now.year}";
|
||||
return [
|
||||
"You are an assistant for performing a web search tool use.",
|
||||
"Use the provided web search results to answer the query.",
|
||||
"After answering, you MUST include a 'Sources:' section with markdown links.",
|
||||
"Use the current year when searching for recent information.",
|
||||
"The current month is $monthYear.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
String _formatResult({
|
||||
required String query,
|
||||
required Map<String, dynamic> response,
|
||||
required int durationMs,
|
||||
}) {
|
||||
final choices = response["choices"];
|
||||
Map<String, dynamic>? message;
|
||||
if (choices is List && choices.isNotEmpty) {
|
||||
final firstChoice = choices.first;
|
||||
if (firstChoice is Map<String, dynamic>) {
|
||||
final rawMessage = firstChoice["message"];
|
||||
if (rawMessage is Map<String, dynamic>) {
|
||||
message = rawMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final content = _extractMessageContent(message);
|
||||
final annotations = _extractAnnotations(message);
|
||||
final sources = _extractSources(annotations);
|
||||
final searchesPerformed = _extractSearchCount(response);
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln("Query: $query")
|
||||
..writeln("Duration: ${_formatDuration(durationMs)}")
|
||||
..writeln("Searches performed: $searchesPerformed")
|
||||
..writeln()
|
||||
..writeln(content.isEmpty ? "No summary returned." : content.trim());
|
||||
|
||||
if (!_containsSourcesSection(content) && sources.isNotEmpty) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln("Sources:");
|
||||
for (final source in sources) {
|
||||
buffer.writeln("- [${source.title}](${source.url})");
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
|
||||
String _extractMessageContent(Map<String, dynamic>? message) {
|
||||
if (message == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
final content = message["content"];
|
||||
if (content is String) {
|
||||
return content;
|
||||
}
|
||||
if (content is List) {
|
||||
final parts = <String>[];
|
||||
for (final item in content) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final type = item["type"];
|
||||
if (type == "text" || type == "output_text") {
|
||||
final text = item["text"];
|
||||
if (text is String && text.isNotEmpty) {
|
||||
parts.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractAnnotations(Map<String, dynamic>? message) {
|
||||
if (message == null) {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
final annotations = <Map<String, dynamic>>[];
|
||||
|
||||
final topLevel = message["annotations"];
|
||||
if (topLevel is List) {
|
||||
for (final item in topLevel) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
annotations.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final content = message["content"];
|
||||
if (content is List) {
|
||||
for (final item in content) {
|
||||
if (item is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
final nested = item["annotations"];
|
||||
if (nested is! List) {
|
||||
continue;
|
||||
}
|
||||
for (final annotation in nested) {
|
||||
if (annotation is Map<String, dynamic>) {
|
||||
annotations.add(annotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
List<_Source> _extractSources(List<Map<String, dynamic>> annotations) {
|
||||
final seenUrls = <String>{};
|
||||
final sources = <_Source>[];
|
||||
|
||||
for (final annotation in annotations) {
|
||||
if (annotation["type"] != "url_citation") {
|
||||
continue;
|
||||
}
|
||||
final citation = annotation["url_citation"];
|
||||
final citationMap = citation is Map<String, dynamic>
|
||||
? citation
|
||||
: annotation;
|
||||
final url = citationMap["url"];
|
||||
if (url is! String || url.isEmpty || !seenUrls.add(url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final title = citationMap["title"];
|
||||
sources.add(
|
||||
_Source(
|
||||
title: title is String && title.isNotEmpty ? title : _hostForUrl(url),
|
||||
url: url,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
int _extractSearchCount(Map<String, dynamic> response) {
|
||||
final usage = response["usage"];
|
||||
if (usage is! Map<String, dynamic>) {
|
||||
return 0;
|
||||
}
|
||||
final serverToolUse = usage["server_tool_use"];
|
||||
if (serverToolUse is! Map<String, dynamic>) {
|
||||
return 0;
|
||||
}
|
||||
return (serverToolUse["web_search_requests"] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
|
||||
List<String> _readStringList(Object? value) {
|
||||
if (value is! List) {
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
return value
|
||||
.whereType<String>()
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool _containsSourcesSection(String content) {
|
||||
return RegExp(r"(^|\n)Sources:\s*$", caseSensitive: false).hasMatch(content);
|
||||
}
|
||||
|
||||
String _hostForUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
return uri?.host.isNotEmpty == true ? uri!.host : url;
|
||||
}
|
||||
|
||||
String _formatDuration(int durationMs) {
|
||||
if (durationMs < 1000) {
|
||||
return "${durationMs}ms";
|
||||
}
|
||||
return "${(durationMs / 1000).toStringAsFixed(1)}s";
|
||||
}
|
||||
|
||||
String _monthName(int month) {
|
||||
const names = <String>[
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
return names[month - 1];
|
||||
}
|
||||
}
|
||||
|
||||
class _Source {
|
||||
const _Source({required this.title, required this.url});
|
||||
|
||||
final String title;
|
||||
final String url;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Model pricing and cost calculation
|
||||
// Ported from modelCost.ts
|
||||
// Model pricing and cost calculation - Vendor-neutral version
|
||||
// Pricing can be loaded from config or remote service
|
||||
|
||||
/// Per-million-token costs for a model
|
||||
class ModelCosts {
|
||||
@@ -8,6 +8,7 @@ class ModelCosts {
|
||||
final double promptCacheWriteTokens;
|
||||
final double promptCacheReadTokens;
|
||||
final double webSearchRequests;
|
||||
final String provider; // Vendor identifier
|
||||
|
||||
const ModelCosts({
|
||||
required this.inputTokens,
|
||||
@@ -15,73 +16,63 @@ class ModelCosts {
|
||||
required this.promptCacheWriteTokens,
|
||||
required this.promptCacheReadTokens,
|
||||
this.webSearchRequests = 0.01,
|
||||
this.provider = 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
// standard Sonnet pricing: $3 input / $15 output per Mtok
|
||||
const costTier3_15 = ModelCosts(
|
||||
// Default cost structure (can be overridden by config)
|
||||
const defaultModelCosts = ModelCosts(
|
||||
inputTokens: 3,
|
||||
outputTokens: 15,
|
||||
promptCacheWriteTokens: 3.75,
|
||||
promptCacheReadTokens: 0.3,
|
||||
provider: 'default',
|
||||
);
|
||||
|
||||
// Opus 4/4.1 pricing: $15/$75
|
||||
const costTier15_75 = ModelCosts(
|
||||
// Cost tiers for reference (not hardcoded to specific vendor)
|
||||
const costTierLow = ModelCosts(
|
||||
inputTokens: 1,
|
||||
outputTokens: 4,
|
||||
promptCacheWriteTokens: 1.25,
|
||||
promptCacheReadTokens: 0.1,
|
||||
provider: 'generic',
|
||||
);
|
||||
|
||||
const costTierMedium = ModelCosts(
|
||||
inputTokens: 3,
|
||||
outputTokens: 15,
|
||||
promptCacheWriteTokens: 3.75,
|
||||
promptCacheReadTokens: 0.3,
|
||||
provider: 'generic',
|
||||
);
|
||||
|
||||
const costTierHigh = ModelCosts(
|
||||
inputTokens: 15,
|
||||
outputTokens: 75,
|
||||
promptCacheWriteTokens: 18.75,
|
||||
promptCacheReadTokens: 1.5,
|
||||
provider: 'generic',
|
||||
);
|
||||
|
||||
// Opus 4.5: $5/$25
|
||||
const costTier5_25 = ModelCosts(
|
||||
inputTokens: 5,
|
||||
outputTokens: 25,
|
||||
promptCacheWriteTokens: 6.25,
|
||||
promptCacheReadTokens: 0.5,
|
||||
);
|
||||
// Pricing map - populated from config/remote service
|
||||
final Map<String, ModelCosts> modelCostMap = {
|
||||
// Can be loaded from: config file, environment, remote service
|
||||
};
|
||||
|
||||
// fast mode Opus 4.6: $30/$150
|
||||
const costTier30_150 = ModelCosts(
|
||||
inputTokens: 30,
|
||||
outputTokens: 150,
|
||||
promptCacheWriteTokens: 37.5,
|
||||
promptCacheReadTokens: 3,
|
||||
);
|
||||
// Cost examples (can be loaded from config)
|
||||
// const costExample = ModelCosts(
|
||||
// inputTokens: 1,
|
||||
// outputTokens: 5,
|
||||
// promptCacheWriteTokens: 1.25,
|
||||
// promptCacheReadTokens: 0.1,
|
||||
// provider: 'example',
|
||||
// );
|
||||
|
||||
// Haiku 3.5: $0.80/$4
|
||||
const costHaiku35 = ModelCosts(
|
||||
inputTokens: 0.8,
|
||||
outputTokens: 4,
|
||||
promptCacheWriteTokens: 1,
|
||||
promptCacheReadTokens: 0.08,
|
||||
);
|
||||
const _defaultUnknownModelCost = defaultModelCosts;
|
||||
|
||||
// Haiku 4.5: $1/$5
|
||||
const costHaiku45 = ModelCosts(
|
||||
inputTokens: 1,
|
||||
outputTokens: 5,
|
||||
promptCacheWriteTokens: 1.25,
|
||||
promptCacheReadTokens: 0.1,
|
||||
);
|
||||
|
||||
const _defaultUnknownModelCost = costTier5_25;
|
||||
|
||||
|
||||
// Model name -> cost mapping
|
||||
// Model name -> cost mapping (can be loaded from config)
|
||||
final Map<String, ModelCosts> modelCosts = {
|
||||
"claude-3-5-haiku": costHaiku35,
|
||||
"claude-haiku-4-5": costHaiku45,
|
||||
"claude-3-5-sonnet-v2": costTier3_15,
|
||||
"claude-3-7-sonnet": costTier3_15,
|
||||
"claude-sonnet-4": costTier3_15,
|
||||
"claude-sonnet-4-5": costTier3_15,
|
||||
"claude-sonnet-4-6": costTier3_15,
|
||||
"claude-opus-4": costTier15_75,
|
||||
"claude-opus-4-1": costTier15_75,
|
||||
"claude-opus-4-5": costTier5_25,
|
||||
"claude-opus-4-6": costTier5_25,
|
||||
// Can be populated from config file
|
||||
};
|
||||
|
||||
|
||||
|
||||
+10
-4
@@ -1,8 +1,9 @@
|
||||
import "package:clawd_code/ui/screens/new_home_screen.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "providers/settings_provider.dart";
|
||||
import "routes/router.dart";
|
||||
|
||||
class ClawdApp extends StatelessWidget {
|
||||
const ClawdApp();
|
||||
@@ -11,10 +12,15 @@ class ClawdApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, _) {
|
||||
return ShadcnApp(
|
||||
return ShadcnApp.router(
|
||||
title: "Clawd",
|
||||
home: NewHomeScreen(),
|
||||
theme: ThemeData(colorScheme: ColorSchemes.darkNeutral, radius: 0.5),
|
||||
routerConfig: AppRouter.router,
|
||||
scaling: const AdaptiveScaling(0.9),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorSchemes.darkGray.rose,
|
||||
density: Density.spaciousDensity,
|
||||
radius: 0.5,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+20
-33
@@ -36,40 +36,27 @@ const List<SelectableAiModel> selectableAiModels = [
|
||||
group: "Recommended",
|
||||
id: "qwen/qwen3-coder-next",
|
||||
label: "Qwen3 Coder Next",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "qwen/qwen3-235b-a22b-2507",
|
||||
label: "Qwen3 235B A22B-2507",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "google/gemma-4-31b-it",
|
||||
label: "Gemma 4 31B IT",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "qwen/qwen3.6-plus",
|
||||
label: "Qwen3.6 Plus",
|
||||
),
|
||||
SelectableAiModel(
|
||||
group: "Recommended",
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
label: "Claude Sonnet 4.6",
|
||||
)
|
||||
|
||||
|
||||
// SelectableAiModel(
|
||||
// group: "Anthropic",
|
||||
// id: "anthropic/claude-sonnet-4.6",
|
||||
// label: "Claude Sonnet 4.6",
|
||||
// ),
|
||||
// SelectableAiModel(
|
||||
// group: "Anthropic",
|
||||
// id: "anthropic/claude-opus-4.6",
|
||||
// label: "Claude Opus 4.6",
|
||||
// ),
|
||||
// SelectableAiModel(
|
||||
// group: "Anthropic",
|
||||
// id: "anthropic/claude-haiku-4.5",
|
||||
// label: "Claude Haiku 4.5",
|
||||
// ),
|
||||
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-5.4", label: "GPT-5.4"),
|
||||
// SelectableAiModel(
|
||||
// group: "OpenAI",
|
||||
// id: "openai/gpt-5.4-mini",
|
||||
// label: "GPT-5.4 Mini",
|
||||
// ),
|
||||
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-4.1", label: "GPT-4.1"),
|
||||
// SelectableAiModel(group: "Qwen", id: "qwen/qwen3.5-9b", label: "Qwen3.5-9B"),
|
||||
// SelectableAiModel(
|
||||
// group: "Qwen",
|
||||
// id: "qwen/qwen3.5-35b-a3b",
|
||||
// label: "Qwen3.5-35B-A3B",
|
||||
// ),
|
||||
// SelectableAiModel(
|
||||
// group: "Qwen",
|
||||
// id: "qwen/qwen3.5-flash-02-23",
|
||||
// label: "Qwen3.5-Flash",
|
||||
// ),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class Attachment {
|
||||
final String name;
|
||||
final String mimeType;
|
||||
final Uint8List data;
|
||||
final DateTime createdAt;
|
||||
|
||||
Attachment({
|
||||
required this.name,
|
||||
required this.mimeType,
|
||||
required this.data,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
bool get isImage => mimeType.startsWith('image/');
|
||||
bool get isPdf => mimeType == 'application/pdf';
|
||||
bool get isText => mimeType.startsWith('text/');
|
||||
|
||||
int get sizeInKB => (data.length / 1024).ceil();
|
||||
String get displayName => name;
|
||||
}
|
||||
+150
-612
@@ -1,18 +1,16 @@
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../../src/project_store.dart";
|
||||
import "../../../src/session/session_types.dart";
|
||||
import "../../constants.dart";
|
||||
import "../../providers/chat_provider.dart";
|
||||
import "../../providers/cost_provider.dart";
|
||||
import "../../providers/home_coordinator.dart";
|
||||
import "../../providers/projects_provider.dart";
|
||||
import "../../providers/session_provider.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "../../widgets/app_header.dart";
|
||||
import "../../widgets/chat_view.dart";
|
||||
import "../../widgets/settings_sheet.dart";
|
||||
import "../../widgets/agents/agents_pane.dart";
|
||||
import "../../widgets/chat/chat_box.dart";
|
||||
import "../../widgets/chat/chat_view.dart";
|
||||
import "../../widgets/common/footer_bar.dart";
|
||||
import "../../widgets/sidebar/sidebar.dart";
|
||||
|
||||
class NewHomeScreen extends StatefulWidget {
|
||||
const NewHomeScreen({super.key});
|
||||
@@ -22,202 +20,34 @@ class NewHomeScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NewHomeScreenState extends State<NewHomeScreen> {
|
||||
late final TextEditingController _messageController;
|
||||
|
||||
final ScrollController _chatScrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageController = TextEditingController();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<HomeCoordinator>().addListener(_onCoordinatorChanged);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
context.read<HomeCoordinator>().removeListener(_onCoordinatorChanged);
|
||||
_chatScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Iterable<MapEntry<String, List<String>>> _filteredModels(String searchQuery) {
|
||||
final normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return _modelGroups.entries;
|
||||
}
|
||||
|
||||
return _modelGroups.entries
|
||||
.map((entry) {
|
||||
final matchingModels = entry.value
|
||||
.where(
|
||||
(modelId) =>
|
||||
modelId.toLowerCase().contains(normalizedQuery) ||
|
||||
_modelLabel(
|
||||
modelId,
|
||||
).toLowerCase().contains(normalizedQuery),
|
||||
)
|
||||
.toList();
|
||||
return MapEntry(entry.key, matchingModels);
|
||||
})
|
||||
.where((entry) => entry.value.isNotEmpty);
|
||||
}
|
||||
|
||||
Map<String, List<String>> get _modelGroups {
|
||||
final groups = <String, List<String>>{};
|
||||
for (final model in selectableAiModels) {
|
||||
groups.putIfAbsent(model.group, () => <String>[]).add(model.id);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
String _modelLabel(String modelId) {
|
||||
for (final model in selectableAiModels) {
|
||||
if (model.id == modelId) {
|
||||
return model.label;
|
||||
}
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
|
||||
Future<void> _pickProjectDirectory() async {
|
||||
try {
|
||||
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: "Select project directory",
|
||||
);
|
||||
|
||||
if (selectedDirectory == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
|
||||
final project = await projectsProvider.addProject(selectedDirectory);
|
||||
if (project == null && mounted) {
|
||||
await _showProjectPickerError(
|
||||
"The selected folder could not be added as a project.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
projectsProvider.selectProject(project!.id);
|
||||
sessionProvider.clearCurrentSession(
|
||||
workingDirectory: project.workingDirectory,
|
||||
);
|
||||
chatProvider.clearConversation();
|
||||
} catch (error, stackTrace) {
|
||||
print("Project directory picker failed: $error");
|
||||
print(stackTrace);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await _showProjectPickerError(error.toString());
|
||||
void _onCoordinatorChanged() {
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
final err = coordinator.error;
|
||||
if (err != null) {
|
||||
coordinator.clearError();
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createNewChat() async {
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
final selectedProject = projectsProvider.selectedProject;
|
||||
if (selectedProject == null) {
|
||||
await _showProjectPickerError(
|
||||
"Choose a project first so the new chat has a working directory.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
|
||||
await sessionProvider.createNewSession(
|
||||
workingDirectory: selectedProject.workingDirectory,
|
||||
name: "New Chat",
|
||||
);
|
||||
chatProvider.setConversation(sessionProvider.getConversationHistory());
|
||||
}
|
||||
|
||||
Future<void> _selectProject(ProjectRecord project) async {
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
|
||||
projectsProvider.selectProject(project.id);
|
||||
if (sessionProvider.currentSession?.workingDirectory ==
|
||||
project.workingDirectory) {
|
||||
return;
|
||||
}
|
||||
sessionProvider.clearCurrentSession(
|
||||
workingDirectory: project.workingDirectory,
|
||||
);
|
||||
chatProvider.clearConversation();
|
||||
}
|
||||
|
||||
Future<void> _openSession(SessionSummary session) async {
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
|
||||
await sessionProvider.loadSession(session.id);
|
||||
chatProvider.setConversation(sessionProvider.getConversationHistory());
|
||||
projectsProvider.selectProjectByWorkingDirectory(
|
||||
sessionProvider.activeWorkingDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final projectsProvider = context.read<ProjectsProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
final selectedProject = projectsProvider.selectedProject;
|
||||
|
||||
if (sessionProvider.currentSession == null) {
|
||||
if (selectedProject == null) {
|
||||
await _showProjectPickerError("Pick a project before starting a chat.");
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionProvider.createNewSession(
|
||||
workingDirectory: selectedProject.workingDirectory,
|
||||
name: "New Chat",
|
||||
);
|
||||
chatProvider.setConversation(sessionProvider.getConversationHistory());
|
||||
}
|
||||
|
||||
_messageController.clear();
|
||||
|
||||
try {
|
||||
await chatProvider.sendMessage(text);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
print("Failed to send message from home screen: $error");
|
||||
print(stackTrace);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await _showProjectPickerError(error.toString());
|
||||
} finally {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await context.read<SessionProvider>().refreshSessions();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopMessage() {
|
||||
context.read<ChatProvider>().stopGenerating();
|
||||
}
|
||||
|
||||
void _openSettings() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const AlertDialog(content: SettingsSheet()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showProjectPickerError(String message) {
|
||||
Future<void> _showError(String message) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
@@ -235,439 +65,78 @@ class _NewHomeScreenState extends State<NewHomeScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final costProvider = context.watch<CostProvider>();
|
||||
|
||||
// Group sessions by working directory
|
||||
final sessionsByProject = <String, List<SessionSummary>>{};
|
||||
for (final session in sessionProvider.sessions) {
|
||||
final workingDirectory = session.workingDirectory ?? '';
|
||||
if (!sessionsByProject.containsKey(workingDirectory)) {
|
||||
sessionsByProject[workingDirectory] = <SessionSummary>[];
|
||||
}
|
||||
sessionsByProject[workingDirectory]!.add(session);
|
||||
}
|
||||
|
||||
final selectedProject = projectsProvider.selectedProject;
|
||||
final selectedWorkingDirectory = selectedProject?.workingDirectory;
|
||||
final currentModel = settingsProvider.normalizeModelId(
|
||||
settingsProvider.settings.model,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
const Gap(16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppHeader(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
|
||||
Sidebar(),
|
||||
|
||||
Gap(1),
|
||||
|
||||
VerticalDivider(),
|
||||
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
leading: const Icon(LucideIcons.folderPlus),
|
||||
leadingGap: 12,
|
||||
onPressed: _pickProjectDirectory,
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, 1),
|
||||
child: const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("New Project"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
leading: const Icon(LucideIcons.circlePlus),
|
||||
leadingGap: 12,
|
||||
onPressed:
|
||||
selectedProject == null || chatProvider.isLoading
|
||||
? null
|
||||
: _createNewChat,
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, 1),
|
||||
child: const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("New Chat"),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_ChatArea(scrollController: _chatScrollController),
|
||||
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 12,
|
||||
child: FullHeightScrollbar(controller: _chatScrollController),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text("All Threads").textSmall.muted,
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: _ThreadsSection(
|
||||
projectsProvider: projectsProvider,
|
||||
sessionProvider: sessionProvider,
|
||||
sessionsByProject: sessionsByProject,
|
||||
onOpenSession: _openSession,
|
||||
onSelectProject: _selectProject,
|
||||
),
|
||||
),
|
||||
AgentsPane(),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
if (selectedProject != null && sessionProvider.currentSession != null)...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
FooterBar(),
|
||||
|
||||
Icon(
|
||||
LucideIcons.messageCircle
|
||||
).iconSmall,
|
||||
|
||||
Gap(8),
|
||||
|
||||
Transform.translate(
|
||||
offset: Offset(0, -1),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
selectedProject.name
|
||||
).textSmall,
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
LucideIcons.slash
|
||||
).iconX2Small,
|
||||
),
|
||||
|
||||
Text(
|
||||
sessionProvider.currentSession!.name
|
||||
).textSmall
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
],
|
||||
|
||||
|
||||
const Gap(18),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 600
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRect(
|
||||
child: chatProvider.messages.isEmpty
|
||||
? _EmptyChatState(
|
||||
projectName: selectedProject?.name,
|
||||
hasProject: selectedProject != null,
|
||||
)
|
||||
: const ChatView(),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
TextField(
|
||||
controller: _messageController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
enabled: !chatProvider.isLoading,
|
||||
placeholder: Text(
|
||||
selectedProject == null
|
||||
? "Choose a project to start chatting"
|
||||
: "Ask a question or type a message",
|
||||
),
|
||||
onSubmitted: chatProvider.isLoading
|
||||
? null
|
||||
: (_) => _sendMessage(),
|
||||
features: [
|
||||
InputFeature.below(
|
||||
Row(
|
||||
children: [
|
||||
IconButton.ghost(
|
||||
onPressed: _pickProjectDirectory,
|
||||
icon: const Icon(LucideIcons.folderSearch),
|
||||
),
|
||||
const Spacer(),
|
||||
Select<String>(
|
||||
itemBuilder: (context, item) {
|
||||
return Text(_modelLabel(item));
|
||||
},
|
||||
popup: SelectPopup.builder(
|
||||
searchPlaceholder: const Text("Search models"),
|
||||
builder: (context, searchQuery) {
|
||||
final filteredModels = searchQuery == null
|
||||
? _modelGroups.entries
|
||||
: _filteredModels(searchQuery);
|
||||
return SelectItemList(
|
||||
children: [
|
||||
for (final entry in filteredModels)
|
||||
SelectGroup(
|
||||
headers: [
|
||||
SelectLabel(child: Text(entry.key)),
|
||||
],
|
||||
children: [
|
||||
for (final modelId in entry.value)
|
||||
SelectItemButton(
|
||||
value: modelId,
|
||||
child: Text(
|
||||
_modelLabel(modelId),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.updateModel(value);
|
||||
}
|
||||
},
|
||||
constraints: const BoxConstraints(minWidth: 220),
|
||||
value: currentModel,
|
||||
placeholder: const Text("Select a model"),
|
||||
),
|
||||
const Gap(10),
|
||||
Button.primary(
|
||||
onPressed: chatProvider.isLoading
|
||||
? _stopMessage
|
||||
: _sendMessage,
|
||||
child: chatProvider.isLoading
|
||||
? Text(
|
||||
chatProvider.isStopping
|
||||
? "Stopping..."
|
||||
: "Stop",
|
||||
)
|
||||
: const Text("Send"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarHint extends StatelessWidget {
|
||||
const _SidebarHint({required this.text});
|
||||
|
||||
final String text;
|
||||
class _ChatArea extends StatelessWidget {
|
||||
|
||||
final ScrollController scrollController;
|
||||
|
||||
const _ChatArea({required this.scrollController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Text(text).textSmall.muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
|
||||
class _ThreadsSection extends StatelessWidget {
|
||||
const _ThreadsSection({
|
||||
required this.projectsProvider,
|
||||
required this.sessionProvider,
|
||||
required this.sessionsByProject,
|
||||
required this.onOpenSession,
|
||||
required this.onSelectProject,
|
||||
});
|
||||
|
||||
final ProjectsProvider projectsProvider;
|
||||
final SessionProvider sessionProvider;
|
||||
final Map<String, List<SessionSummary>> sessionsByProject;
|
||||
final ValueChanged<SessionSummary> onOpenSession;
|
||||
final ValueChanged<ProjectRecord> onSelectProject;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sort sessions by update time (newest first) within each project
|
||||
final sortedSessionsByProject = <String, List<SessionSummary>>{};
|
||||
sessionsByProject.forEach((workingDirectory, sessions) {
|
||||
final sortedSessions = List<SessionSummary>.from(sessions)
|
||||
..sort((a, b) => b.updated.compareTo(a.updated));
|
||||
sortedSessionsByProject[workingDirectory] = sortedSessions;
|
||||
});
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||
children: [
|
||||
if (projectsProvider.projects.isEmpty)
|
||||
const _SidebarHint(text: "No projects yet")
|
||||
else
|
||||
for (final project in projectsProvider.projects)
|
||||
...[
|
||||
// Project header
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
onPressed: () => onSelectProject(project),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(
|
||||
project.name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Project sessions
|
||||
if (sortedSessionsByProject[project.workingDirectory]?.isEmpty ?? true)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||
child: _SidebarHint(text: "No threads yet"),
|
||||
)
|
||||
else
|
||||
for (final session in sortedSessionsByProject[project.workingDirectory]!)
|
||||
_SidebarSessionTile(
|
||||
session: session,
|
||||
isSelected: sessionProvider.currentSessionId == session.id,
|
||||
onTap: () => onOpenSession(session),
|
||||
),
|
||||
const Divider(height: 16),
|
||||
],
|
||||
// Handle sessions that don't belong to any current project
|
||||
if (sortedSessionsByProject.keys.any((key) => !projectsProvider.projects.any((project) => project.workingDirectory == key)))
|
||||
...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||
child: Text(
|
||||
"Sessions Without Projects",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final entry in sortedSessionsByProject.entries)
|
||||
if (!projectsProvider.projects.any((project) => project.workingDirectory == entry.key) && entry.key.isNotEmpty)
|
||||
for (final session in entry.value)
|
||||
_SidebarSessionTile(
|
||||
session: session,
|
||||
isSelected: sessionProvider.currentSessionId == session.id,
|
||||
onTap: () => onOpenSession(session),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarSessionTile extends StatelessWidget {
|
||||
const _SidebarSessionTile({
|
||||
required this.session,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final SessionSummary session;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
|
||||
child: Text(
|
||||
session.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
|
||||
).textSmall,
|
||||
trailing: Text(
|
||||
_formatRelativeTime(session.updated),
|
||||
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
|
||||
).muted.textSmall,
|
||||
onPressed: () {
|
||||
onTap();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class _EmptyChatState extends StatelessWidget {
|
||||
const _EmptyChatState({required this.projectName, required this.hasProject});
|
||||
|
||||
final String? projectName;
|
||||
final bool hasProject;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(LucideIcons.messagesSquare, size: 28),
|
||||
const Gap(16),
|
||||
Text(
|
||||
hasProject
|
||||
? "Ready to chat about ${projectName ?? "this project"}"
|
||||
: "Choose a project to begin",
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
Expanded(
|
||||
child: chatProvider.messages.isEmpty
|
||||
? _EmptyChatState()
|
||||
: ChatView(scrollController: scrollController),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
hasProject
|
||||
? "This chat will use the selected folder as its working directory."
|
||||
: "The desktop app uses the picked folder instead of the shell launch directory.",
|
||||
textAlign: TextAlign.center,
|
||||
).textSmall.muted,
|
||||
|
||||
ChatBox(),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -675,23 +144,92 @@ class _EmptyChatState extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatRelativeTime(DateTime timestamp) {
|
||||
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return "just now";
|
||||
}
|
||||
if (difference.inHours < 1) {
|
||||
return "${difference.inMinutes}m";
|
||||
}
|
||||
if (difference.inDays < 1) {
|
||||
return "${difference.inHours}h";
|
||||
}
|
||||
if (difference.inDays < 7) {
|
||||
return "${difference.inDays}d";
|
||||
}
|
||||
class _EmptyChatState extends StatelessWidget {
|
||||
|
||||
final month = timestamp.month.toString().padLeft(2, "0");
|
||||
final day = timestamp.day.toString().padLeft(2, "0");
|
||||
return "${timestamp.year}-$month-$day";
|
||||
const _EmptyChatState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final projects = projectsProvider.projects;
|
||||
final selected = projectsProvider.selectedProject;
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
const Icon(LucideIcons.messagesSquare, size: 28),
|
||||
const Gap(16),
|
||||
Text(
|
||||
"Ask the agency anything",
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
"Select a project and thread from the sidebar, or start a new chat.",
|
||||
textAlign: TextAlign.center,
|
||||
).textSmall.muted,
|
||||
|
||||
const Gap(24),
|
||||
|
||||
Select<ProjectRecord>(
|
||||
itemBuilder: (context, item) => Text(item.name),
|
||||
popup: SelectPopup.builder(
|
||||
searchPlaceholder: const Text("Search projects"),
|
||||
builder: (context, searchQuery) {
|
||||
final filtered = searchQuery == null || searchQuery.isEmpty
|
||||
? projects
|
||||
: projects.where((p) =>
|
||||
p.name.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
||||
p.workingDirectory.toLowerCase().contains(searchQuery.toLowerCase())
|
||||
).toList();
|
||||
|
||||
return SelectItemList(
|
||||
children: [
|
||||
for (final project in filtered)
|
||||
SelectItemButton(
|
||||
value: project,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(project.name),
|
||||
Text(project.workingDirectory).textSmall.muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
onChanged: (project) {
|
||||
if (project != null) coordinator.selectProject(project);
|
||||
},
|
||||
constraints: const BoxConstraints(minWidth: 240),
|
||||
value: selected,
|
||||
placeholder: const Text("Select a project"),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract class HomeScreenRoute {
|
||||
static const path = '/';
|
||||
static const name = 'home';
|
||||
|
||||
static GoRoute get route => GoRoute(
|
||||
path: path,
|
||||
name: name,
|
||||
builder: (context, state) => const NewHomeScreen(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../../../src/project_store.dart";
|
||||
import "../../../../src/session/session_types.dart";
|
||||
import "../../../providers/projects_provider.dart";
|
||||
import "../../../providers/session_provider.dart";
|
||||
|
||||
class ThreadsSection extends StatelessWidget {
|
||||
const ThreadsSection({
|
||||
required this.projectsProvider,
|
||||
required this.sessionProvider,
|
||||
required this.sessionsByProject,
|
||||
required this.onOpenSession,
|
||||
required this.onSelectProject,
|
||||
required this.onDeleteSession,
|
||||
});
|
||||
|
||||
final ProjectsProvider projectsProvider;
|
||||
final SessionProvider sessionProvider;
|
||||
final Map<String, List<SessionSummary>> sessionsByProject;
|
||||
final ValueChanged<SessionSummary> onOpenSession;
|
||||
final ValueChanged<ProjectRecord> onSelectProject;
|
||||
final ValueChanged<SessionSummary> onDeleteSession;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sort sessions by update time (newest first) within each project
|
||||
final sortedSessionsByProject = <String, List<SessionSummary>>{};
|
||||
sessionsByProject.forEach((workingDirectory, sessions) {
|
||||
final sortedSessions = List<SessionSummary>.from(sessions)
|
||||
..sort((a, b) => b.updated.compareTo(a.updated));
|
||||
sortedSessionsByProject[workingDirectory] = sortedSessions;
|
||||
});
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||
children: [
|
||||
if (projectsProvider.projects.isEmpty)
|
||||
const _SidebarHint(text: "No projects yet")
|
||||
else
|
||||
for (final project in projectsProvider.projects) ...[
|
||||
// Project header
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
onPressed: () => onSelectProject(project),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(
|
||||
project.name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Project sessions
|
||||
if (sortedSessionsByProject[project.workingDirectory]?.isEmpty ??
|
||||
true)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||
child: _SidebarHint(text: "No threads yet"),
|
||||
)
|
||||
else
|
||||
for (final session
|
||||
in sortedSessionsByProject[project.workingDirectory]!)
|
||||
_SidebarSessionTile(
|
||||
session: session,
|
||||
isSelected: sessionProvider.currentSessionId == session.id,
|
||||
onTap: () => onOpenSession(session),
|
||||
onDelete: () => onDeleteSession(session),
|
||||
),
|
||||
const Divider(height: 16),
|
||||
],
|
||||
// Handle sessions that don't belong to any current project
|
||||
if (sortedSessionsByProject.keys.any(
|
||||
(key) => !projectsProvider.projects.any(
|
||||
(project) => project.workingDirectory == key,
|
||||
),
|
||||
)) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||
child: Text(
|
||||
"Sessions Without Projects",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final entry in sortedSessionsByProject.entries)
|
||||
if (!projectsProvider.projects.any(
|
||||
(project) => project.workingDirectory == entry.key,
|
||||
) &&
|
||||
entry.key.isNotEmpty)
|
||||
for (final session in entry.value)
|
||||
_SidebarSessionTile(
|
||||
session: session,
|
||||
isSelected: sessionProvider.currentSessionId == session.id,
|
||||
onTap: () => onOpenSession(session),
|
||||
onDelete: () => onDeleteSession(session),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarHint extends StatelessWidget {
|
||||
const _SidebarHint({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Text(text).textSmall.muted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarSessionTile extends StatelessWidget {
|
||||
const _SidebarSessionTile({
|
||||
required this.session,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final SessionSummary session;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ContextMenu(
|
||||
items: [
|
||||
MenuButton(
|
||||
onPressed: (context) {
|
||||
onDelete();
|
||||
},
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
|
||||
child: Text(
|
||||
session.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
|
||||
).textSmall,
|
||||
trailing: Text(
|
||||
_formatRelativeTime(session.updated),
|
||||
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
|
||||
).muted.textSmall,
|
||||
onPressed: () {
|
||||
onTap();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatRelativeTime(DateTime timestamp) {
|
||||
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return "just now";
|
||||
}
|
||||
if (difference.inHours < 1) {
|
||||
return "${difference.inMinutes}m";
|
||||
}
|
||||
if (difference.inDays < 1) {
|
||||
return "${difference.inHours}h";
|
||||
}
|
||||
if (difference.inDays < 7) {
|
||||
return "${difference.inDays}d";
|
||||
}
|
||||
|
||||
final month = timestamp.month.toString().padLeft(2, "0");
|
||||
final day = timestamp.day.toString().padLeft(2, "0");
|
||||
return "${timestamp.year}-$month-$day";
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class ProjectDetailPage extends StatelessWidget {
|
||||
const ProjectDetailPage({
|
||||
super.key,
|
||||
required this.projectId,
|
||||
this.tab = 'overview',
|
||||
});
|
||||
|
||||
final String projectId;
|
||||
final String tab;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
title: Text("Project: $projectId"),
|
||||
leading: [
|
||||
IconButton.ghost(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
// Tab navigation
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTabButton(context, 'overview', 'Overview'),
|
||||
const Gap(8),
|
||||
_buildTabButton(context, 'files', 'Files'),
|
||||
const Gap(8),
|
||||
_buildTabButton(context, 'settings', 'Settings'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildTabContent(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(BuildContext context, String tabName, String label) {
|
||||
final isActive = tab == tabName;
|
||||
return Button(
|
||||
style: isActive ? ButtonStyle.secondary() : ButtonStyle.ghost(),
|
||||
onPressed: () => context.go(
|
||||
ProjectDetailRoute.pathWithParams(
|
||||
projectId: projectId,
|
||||
tab: tabName,
|
||||
),
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabContent() {
|
||||
switch (tab) {
|
||||
case 'files':
|
||||
return const Center(child: Text("Files tab content"));
|
||||
case 'settings':
|
||||
return const Center(child: Text("Settings tab content"));
|
||||
case 'overview':
|
||||
default:
|
||||
return const Center(child: Text("Project overview content"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GoRouter routes for the project detail page
|
||||
abstract class ProjectDetailRoute {
|
||||
static const path = '/projects/:projectId';
|
||||
static const name = 'project_detail';
|
||||
|
||||
static String pathWithParams({
|
||||
required String projectId,
|
||||
String tab = 'overview',
|
||||
}) {
|
||||
return '/projects/$projectId?tab=$tab';
|
||||
}
|
||||
|
||||
static GoRoute get route => GoRoute(
|
||||
path: path,
|
||||
name: name,
|
||||
builder: (context, state) {
|
||||
final projectId = state.pathParameters['projectId']!;
|
||||
final tab = state.uri.queryParameters['tab'] ?? 'overview';
|
||||
|
||||
return ProjectDetailPage(
|
||||
projectId: projectId,
|
||||
tab: tab,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "widgets/setting_card.dart";
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
title: const Text("Settings"),
|
||||
leading: [
|
||||
IconButton.ghost(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SettingCard(
|
||||
title: "Appearance",
|
||||
description: "Customize theme, colors, and layout",
|
||||
icon: LucideIcons.palette,
|
||||
onTap: () {
|
||||
// Could navigate to appearance settings
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
SettingCard(
|
||||
title: "Models",
|
||||
description: "Configure AI model preferences",
|
||||
icon: LucideIcons.brain,
|
||||
onTap: () {
|
||||
// Could navigate to model settings
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
SettingCard(
|
||||
title: "Advanced",
|
||||
description: "Developer options and advanced settings",
|
||||
icon: LucideIcons.settings2,
|
||||
onTap: () {
|
||||
// Could navigate to advanced settings
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GoRouter routes for the settings page
|
||||
abstract class SettingsRoute {
|
||||
static const path = '/settings';
|
||||
static const name = 'settings';
|
||||
|
||||
static GoRoute get route => GoRoute(
|
||||
path: path,
|
||||
name: name,
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class SettingCard extends StatelessWidget {
|
||||
const SettingCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon).iconLarge,
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title).textLarge,
|
||||
const Gap(4),
|
||||
Text(description).textSmall.muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null) ...[
|
||||
const Gap(8),
|
||||
IconButton.ghost(
|
||||
onPressed: onTap,
|
||||
icon: const Icon(LucideIcons.chevronRight),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,48 +3,130 @@ import "dart:convert";
|
||||
|
||||
import "../../src/chat/tool_loop_service.dart";
|
||||
import "../../src/api/openrouter_client.dart";
|
||||
import "../../src/hooks/hook_loader.dart";
|
||||
import "../../src/hooks/hook_runner.dart";
|
||||
import "../../src/hooks/hook_types.dart";
|
||||
import "../../src/permissions/permission_types.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";
|
||||
|
||||
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 ChatProvider extends ChangeNotifier {
|
||||
ChatProvider(this._settingsProvider);
|
||||
ChatProvider(this._settingsProvider) {
|
||||
_initHooks();
|
||||
}
|
||||
|
||||
final SettingsProvider _settingsProvider;
|
||||
final ToolLoopService _toolLoopService = ToolLoopService();
|
||||
ToolLoopService _toolLoopService = ToolLoopService();
|
||||
HookRunner? _hookRunner;
|
||||
ConversationHistory? _conversationHistory;
|
||||
OpenRouterClient? _client;
|
||||
bool _stopRequested = false;
|
||||
PendingPermission? _pendingPermission;
|
||||
|
||||
PendingPermission? get pendingPermission => _pendingPermission;
|
||||
|
||||
Future<void> _initHooks() async {
|
||||
try {
|
||||
final hooks = await HookLoader.loadHooks();
|
||||
_hookRunner = HookRunner(hooks: hooks);
|
||||
_toolLoopService = ToolLoopService(hookRunner: _hookRunner);
|
||||
} catch (e) {
|
||||
// hooks are optional, carry on without them
|
||||
print("Hook init failed: $e");
|
||||
}
|
||||
}
|
||||
|
||||
List<Message> _messages = <Message>[];
|
||||
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
|
||||
bool isLoading = false;
|
||||
final List<QueuedMessage> _messageQueue = [];
|
||||
|
||||
List<Message> get messages => _messages;
|
||||
int get messageCount => _messages.length;
|
||||
List<Message> get messages => _conversationHistory?.getMessages() ?? const [];
|
||||
int get messageCount => messages.length;
|
||||
String? get workingDirectory => _conversationHistory?.session?.workingDirectory;
|
||||
|
||||
/// Context window size from the last API response — derived from persisted
|
||||
/// message data, same as Claude Code (walks backwards to find the last
|
||||
/// assistant message that has contextTokens set).
|
||||
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;
|
||||
}
|
||||
bool get hasConversation => _conversationHistory != null;
|
||||
bool get isStopping => _stopRequested;
|
||||
int get queuedMessageCount => _messageQueue.length;
|
||||
|
||||
// only user-visible messages (priority != now)
|
||||
List<String> get queuedMessages =>
|
||||
List.unmodifiable(_messageQueue.map((m) => m.text));
|
||||
|
||||
void removeQueuedMessage(int index) {
|
||||
if (index < 0 || index >= _messageQueue.length) return;
|
||||
_messageQueue.removeAt(index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void setConversation(ConversationHistory history) {
|
||||
_conversationHistory = history;
|
||||
_messages = history.getMessages();
|
||||
_apiMessages = _buildApiMessages(_messages);
|
||||
_apiMessages = _buildApiMessages(history.getMessages());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearConversation() {
|
||||
_conversationHistory = null;
|
||||
_messages = <Message>[];
|
||||
_apiMessages = <Map<String, dynamic>>[];
|
||||
_messageQueue.clear();
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
Future<void> sendMessage(String text, {QueuePriority priority = QueuePriority.next}) async {
|
||||
if (text.isEmpty || _conversationHistory == null) return;
|
||||
|
||||
if (isLoading) {
|
||||
_messageQueue.add(QueuedMessage(text: text, priority: priority));
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final apiKey = _settingsProvider.settings.openRouterApiKey;
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
throw Exception(
|
||||
@@ -72,25 +154,35 @@ class ChatProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// fire UserPromptSubmit hook
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.userPromptSubmit,
|
||||
input: {"message": 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 advisorModel = _settingsProvider.settings.advisorModel;
|
||||
|
||||
final toolLoopResult = await _toolLoopService.runTurn(
|
||||
client: _client!,
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
getSettings: () => _settingsProvider.settings,
|
||||
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
|
||||
userText: text,
|
||||
workingDirectory: workingDirectory,
|
||||
advisorModel: advisorModel,
|
||||
onToolCall: (toolName, input) {
|
||||
_conversationHistory!.addMessage(
|
||||
"tool",
|
||||
_formatToolCall(toolName, input),
|
||||
);
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
notifyListeners();
|
||||
},
|
||||
onToolResult: (toolName, result) {
|
||||
@@ -98,7 +190,6 @@ class ChatProvider extends ChangeNotifier {
|
||||
"tool",
|
||||
_formatToolResult(toolName, result),
|
||||
);
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
notifyListeners();
|
||||
},
|
||||
onAssistantTextDelta: (delta) {
|
||||
@@ -107,26 +198,38 @@ class ChatProvider extends ChangeNotifier {
|
||||
hasStreamingAssistantMessage = true;
|
||||
}
|
||||
_conversationHistory!.appendToLastMessage(delta);
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
notifyListeners();
|
||||
},
|
||||
onAssistantMessageComplete: () {
|
||||
hasStreamingAssistantMessage = false;
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
notifyListeners();
|
||||
},
|
||||
onPermissionRequired: (toolName, input) async {
|
||||
final pending = PendingPermission(toolName: toolName, input: input);
|
||||
_pendingPermission = pending;
|
||||
notifyListeners();
|
||||
final decision = await pending.future;
|
||||
_pendingPermission = null;
|
||||
notifyListeners();
|
||||
return decision;
|
||||
},
|
||||
);
|
||||
_apiMessages = toolLoopResult.apiMessages;
|
||||
|
||||
final ct = toolLoopResult.response.contextTokens;
|
||||
|
||||
// add assistant message to visible conversation
|
||||
if (!toolLoopResult.finalResponseWasStreamed) {
|
||||
_conversationHistory!.addMessage(
|
||||
"assistant",
|
||||
toolLoopResult.responseText,
|
||||
tokens: toolLoopResult.response.outputTokens,
|
||||
contextTokens: ct,
|
||||
);
|
||||
} else {
|
||||
// streamed message was built incrementally — patch contextTokens onto it
|
||||
_conversationHistory!.setLastMessageContextTokens(ct);
|
||||
}
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
|
||||
// track cost (set to 0 for now — OpenRouter pricing varies by model)
|
||||
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
|
||||
@@ -138,6 +241,8 @@ class ChatProvider extends ChangeNotifier {
|
||||
outputTokens: outputTokens,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
webSearchRequests: toolLoopResult.webSearchRequests,
|
||||
webFetchRequests: toolLoopResult.webFetchRequests,
|
||||
model: toolLoopResult.response.model,
|
||||
);
|
||||
|
||||
@@ -154,7 +259,7 @@ class ChatProvider extends ChangeNotifier {
|
||||
if (error is RequestCancelledException) {
|
||||
_conversationHistory!.addMessage("assistant", "Generation stopped.");
|
||||
final session = _conversationHistory!.session;
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
|
||||
if (session != null) {
|
||||
await SessionStore.instance.saveSession(session);
|
||||
}
|
||||
@@ -171,7 +276,7 @@ class ChatProvider extends ChangeNotifier {
|
||||
);
|
||||
|
||||
final session = _conversationHistory!.session;
|
||||
_messages = _conversationHistory!.getMessages();
|
||||
|
||||
if (session != null) {
|
||||
await SessionStore.instance.saveSession(session);
|
||||
}
|
||||
@@ -183,6 +288,26 @@ class ChatProvider extends ChangeNotifier {
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final next = _dequeue();
|
||||
if (next != null) {
|
||||
notifyListeners();
|
||||
await sendMessage(next.text, priority: next.priority);
|
||||
}
|
||||
}
|
||||
|
||||
void resolvePermission(PermissionDecision decision) async {
|
||||
final pending = _pendingPermission;
|
||||
if (pending == null) return;
|
||||
|
||||
if (decision == PermissionDecision.allowAlways) {
|
||||
// persist to settings so this tool is auto-allowed from now on
|
||||
await _settingsProvider.addAlwaysAllowRule(pending.toolName);
|
||||
}
|
||||
|
||||
pending.resolve(decision);
|
||||
_pendingPermission = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void stopGenerating() {
|
||||
@@ -190,10 +315,15 @@ class ChatProvider extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingPermission?.resolve(PermissionDecision.reject);
|
||||
_pendingPermission = null;
|
||||
_messageQueue.clear();
|
||||
_stopRequested = true;
|
||||
print("Stopping active turn");
|
||||
_client?.cancelActiveRequest();
|
||||
notifyListeners();
|
||||
|
||||
_hookRunner?.runHooksForKind(HookKind.stop);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -232,7 +362,10 @@ class ChatProvider extends ChangeNotifier {
|
||||
|
||||
String _formatToolCall(String toolName, Map<String, dynamic> input) {
|
||||
const encoder = JsonEncoder.withIndent(" ");
|
||||
return "$toolName call\n${encoder.convert(input)}";
|
||||
final visibleInput = Map<String, dynamic>.fromEntries(
|
||||
input.entries.where((entry) => !entry.key.startsWith("_")),
|
||||
);
|
||||
return "$toolName call\n${encoder.convert(visibleInput)}";
|
||||
}
|
||||
|
||||
String _formatToolResult(String toolName, String result) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "../../src/project_store.dart";
|
||||
import "../../src/session/session_types.dart";
|
||||
import "chat_provider.dart";
|
||||
import "projects_provider.dart";
|
||||
import "session_provider.dart";
|
||||
import "settings_provider.dart";
|
||||
|
||||
class HomeCoordinator extends ChangeNotifier {
|
||||
|
||||
HomeCoordinator(this._projects, this._session, this._chat, this._settings);
|
||||
|
||||
final ProjectsProvider _projects;
|
||||
final SessionProvider _session;
|
||||
final ChatProvider _chat;
|
||||
final SettingsProvider _settings;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setError(String msg) {
|
||||
_error = msg;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
Future<void> pickProjectDirectory() async {
|
||||
try {
|
||||
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: "Select project directory",
|
||||
);
|
||||
|
||||
if (selectedDirectory == null) return;
|
||||
|
||||
final project = await _projects.addProject(selectedDirectory);
|
||||
if (project == null) {
|
||||
_setError("The selected folder could not be added as a project.");
|
||||
return;
|
||||
}
|
||||
|
||||
_projects.selectProject(project.id);
|
||||
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
|
||||
_chat.clearConversation();
|
||||
await _settings.setActiveProject(project.workingDirectory);
|
||||
} catch (e, st) {
|
||||
print("Project directory picker failed: $e");
|
||||
print(st);
|
||||
_setError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createNewChat() async {
|
||||
final selectedProject = _projects.selectedProject;
|
||||
if (selectedProject == null) {
|
||||
_setError("Choose a project first so the new chat has a working directory.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _session.createNewSession(
|
||||
workingDirectory: selectedProject.workingDirectory,
|
||||
name: "New Chat",
|
||||
model: _settings.settings.model,
|
||||
);
|
||||
_settings.setThreadModel(_settings.settings.model);
|
||||
_chat.setConversation(_session.getConversationHistory());
|
||||
}
|
||||
|
||||
Future<void> selectProject(ProjectRecord project) async {
|
||||
_projects.selectProject(project.id);
|
||||
await _settings.setActiveProject(project.workingDirectory);
|
||||
|
||||
if (_session.currentSession?.workingDirectory == project.workingDirectory) return;
|
||||
|
||||
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
|
||||
_settings.setThreadModel(null);
|
||||
_chat.clearConversation();
|
||||
}
|
||||
|
||||
Future<void> openSession(SessionSummary session) async {
|
||||
await _session.loadSession(session);
|
||||
_chat.setConversation(_session.getConversationHistory());
|
||||
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
|
||||
_settings.setThreadModel(_session.currentSession?.model);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
if (text.isEmpty) return;
|
||||
|
||||
if (_session.currentSession == null) {
|
||||
final selectedProject = _projects.selectedProject;
|
||||
if (selectedProject == null) {
|
||||
_setError("Pick a project before starting a chat.");
|
||||
return;
|
||||
}
|
||||
await _session.createNewSession(
|
||||
workingDirectory: selectedProject.workingDirectory,
|
||||
name: "New Chat",
|
||||
model: _settings.settings.model,
|
||||
);
|
||||
_settings.setThreadModel(_settings.settings.model);
|
||||
_chat.setConversation(_session.getConversationHistory());
|
||||
}
|
||||
|
||||
try {
|
||||
await _chat.sendMessage(text);
|
||||
} catch (e, st) {
|
||||
print("Failed to send message: $e");
|
||||
print(st);
|
||||
_setError(e.toString());
|
||||
} finally {
|
||||
await _session.refreshSessions();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSession(SessionSummary session) async {
|
||||
await _session.deleteSession(session);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:uuid/uuid.dart";
|
||||
|
||||
import "../../src/project_store.dart";
|
||||
import "../../src/session/conversation_history.dart";
|
||||
import "../../src/session/session_store.dart";
|
||||
import "../../src/session/session_types.dart";
|
||||
|
||||
class SessionProvider extends ChangeNotifier {
|
||||
SessionProvider() {
|
||||
SessionProvider(this._projectStore) {
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
final ProjectStore _projectStore;
|
||||
final SessionStore _sessionStore = SessionStore.instance;
|
||||
final ConversationHistory _conversationHistory = ConversationHistory();
|
||||
|
||||
@@ -59,7 +61,12 @@ class SessionProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
try {
|
||||
_sessions = await _sessionStore.listSessions();
|
||||
final workingDirs = _projectStore.projects
|
||||
.map((p) => p.workingDirectory)
|
||||
.where((d) => d.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
_sessions = await _sessionStore.listAllSessions(workingDirs);
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to load sessions", error, stackTrace);
|
||||
@@ -70,6 +77,7 @@ class SessionProvider extends ChangeNotifier {
|
||||
Future<void> createNewSession({
|
||||
String? workingDirectory,
|
||||
String? name,
|
||||
String? model,
|
||||
}) async {
|
||||
try {
|
||||
const uuid = Uuid();
|
||||
@@ -86,6 +94,7 @@ class SessionProvider extends ChangeNotifier {
|
||||
normalizedDirectory == null || normalizedDirectory.isEmpty
|
||||
? null
|
||||
: normalizedDirectory,
|
||||
model: model,
|
||||
);
|
||||
|
||||
await _sessionStore.saveSession(newSession);
|
||||
@@ -101,29 +110,38 @@ class SessionProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadSession(String id) async {
|
||||
Future<void> loadSession(SessionSummary summary) async {
|
||||
try {
|
||||
final session = await _sessionStore.loadSession(id);
|
||||
final workingDir = summary.workingDirectory;
|
||||
if (workingDir == null || workingDir.isEmpty) return;
|
||||
|
||||
final session = await _sessionStore.loadSession(
|
||||
summary.id,
|
||||
workingDirectory: workingDir,
|
||||
);
|
||||
if (session != null) {
|
||||
_conversationHistory.setSession(session);
|
||||
_currentSession = session;
|
||||
_currentSessionId = id;
|
||||
_currentSessionId = summary.id;
|
||||
_activeWorkingDirectory = session.workingDirectory;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to load session $id", error, stackTrace);
|
||||
_logException("Failed to load session ${summary.id}", error, stackTrace);
|
||||
_currentSession = null;
|
||||
_currentSessionId = null;
|
||||
_activeWorkingDirectory = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSession(String id) async {
|
||||
Future<void> deleteSession(SessionSummary summary) async {
|
||||
try {
|
||||
await _sessionStore.deleteSession(id);
|
||||
final workingDir = summary.workingDirectory;
|
||||
if (workingDir == null || workingDir.isEmpty) return;
|
||||
|
||||
if (_currentSessionId == id) {
|
||||
await _sessionStore.deleteSession(summary.id, workingDirectory: workingDir);
|
||||
|
||||
if (_currentSessionId == summary.id) {
|
||||
_conversationHistory.setSession(
|
||||
ConversationSession(
|
||||
id: "",
|
||||
@@ -140,7 +158,7 @@ class SessionProvider extends ChangeNotifier {
|
||||
await _loadSessions();
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logException("Failed to delete session $id", error, stackTrace);
|
||||
_logException("Failed to delete session ${summary.id}", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +170,15 @@ class SessionProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the model on the current in-memory session and persists it
|
||||
Future<void> updateSessionModel(String model) async {
|
||||
final session = _currentSession;
|
||||
if (session == null) return;
|
||||
|
||||
session.model = model;
|
||||
await _sessionStore.saveSession(session);
|
||||
}
|
||||
|
||||
ConversationHistory getConversationHistory() => _conversationHistory;
|
||||
|
||||
void _logException(String message, Object error, StackTrace stackTrace) {
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
import "../../src/local_state.dart";
|
||||
import "../../src/project_settings_store.dart";
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {
|
||||
SettingsProvider(this._settingsStore) : settings = _settingsStore.settings;
|
||||
SettingsProvider(this._settingsStore) : _globalSettings = _settingsStore.settings;
|
||||
|
||||
static const Map<String, String> _legacyModelAliases = {
|
||||
"google/gemini-2.0-flash": "google/gemini-2.0-flash-001",
|
||||
};
|
||||
|
||||
final SettingsStore _settingsStore;
|
||||
LocalSettings settings;
|
||||
|
||||
LocalSettings _globalSettings;
|
||||
LocalSettings? _projectSettings;
|
||||
String? _threadModel;
|
||||
|
||||
String? _activeProjectDir;
|
||||
|
||||
// Effective settings: global → project override → thread model
|
||||
LocalSettings get settings {
|
||||
var merged = _globalSettings.mergeWith(_projectSettings);
|
||||
if (_threadModel != null && _threadModel!.isNotEmpty) {
|
||||
merged = merged.copyWith(model: _threadModel);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
String normalizeModelId(String? modelId) {
|
||||
if (modelId == null || modelId.isEmpty) {
|
||||
@@ -20,12 +35,36 @@ class SettingsProvider extends ChangeNotifier {
|
||||
return _legacyModelAliases[modelId] ?? modelId;
|
||||
}
|
||||
|
||||
// Called when the active project changes
|
||||
Future<void> setActiveProject(String? workingDirectory) async {
|
||||
_activeProjectDir = workingDirectory;
|
||||
_projectSettings = null;
|
||||
_threadModel = null;
|
||||
|
||||
if (workingDirectory != null && workingDirectory.isNotEmpty) {
|
||||
_projectSettings = await ProjectSettingsStore.instance.load(workingDirectory);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Called when a thread is loaded or cleared
|
||||
void setThreadModel(String? model) {
|
||||
_threadModel = model != null ? normalizeModelId(model) : null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateModel(String newModel) async {
|
||||
final normalizedModel = normalizeModelId(newModel);
|
||||
final normalized = normalizeModelId(newModel);
|
||||
|
||||
// update thread model in memory
|
||||
_threadModel = normalized;
|
||||
|
||||
// also persist to global settings as the new default
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(model: normalizedModel),
|
||||
(current) => current.copyWith(model: normalized),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -33,13 +72,13 @@ class SettingsProvider extends ChangeNotifier {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(openRouterApiKey: newKey),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateTheme(String newTheme) async {
|
||||
await _settingsStore.update((current) => current.copyWith(theme: newTheme));
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -47,13 +86,35 @@ class SettingsProvider extends ChangeNotifier {
|
||||
await _settingsStore.update(
|
||||
(current) => current.copyWith(effortLevel: newLevel),
|
||||
);
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> addAlwaysAllowRule(String toolName) async {
|
||||
final current = _globalSettings.alwaysAllowRules;
|
||||
if (current.contains(toolName)) return;
|
||||
await _settingsStore.update(
|
||||
(s) => s.copyWith(alwaysAllowRules: [...current, toolName]),
|
||||
);
|
||||
_globalSettings = _settingsStore.settings;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
await _settingsStore.update((_) => const LocalSettings());
|
||||
settings = _settingsStore.settings;
|
||||
_globalSettings = _settingsStore.settings;
|
||||
_projectSettings = null;
|
||||
_threadModel = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Save project-level settings override
|
||||
Future<void> updateProjectSetting(LocalSettings projectOverride) async {
|
||||
final dir = _activeProjectDir;
|
||||
if (dir == null || dir.isEmpty) return;
|
||||
|
||||
await ProjectSettingsStore.instance.save(dir, projectOverride);
|
||||
_projectSettings = projectOverride;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import "package:go_router/go_router.dart";
|
||||
|
||||
import "../pages/home_screen/page.dart";
|
||||
import "../pages/settings/page.dart";
|
||||
import "../pages/project_detail/page.dart";
|
||||
|
||||
/// Application router configuration
|
||||
class AppRouter {
|
||||
/// List of all routes in the application
|
||||
static final routes = [
|
||||
HomeScreenRoute.route,
|
||||
SettingsRoute.route,
|
||||
ProjectDetailRoute.route,
|
||||
];
|
||||
|
||||
/// The main GoRouter instance
|
||||
static final GoRouter router = GoRouter(
|
||||
routes: routes,
|
||||
initialLocation: HomeScreenRoute.path,
|
||||
debugLogDiagnostics: true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
String formatRelativeTime(DateTime timestamp) {
|
||||
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return "Just now";
|
||||
}
|
||||
if (difference.inHours < 1) {
|
||||
return "${difference.inMinutes}m";
|
||||
}
|
||||
if (difference.inDays < 1) {
|
||||
return "${difference.inHours}h";
|
||||
}
|
||||
if (difference.inDays < 7) {
|
||||
return "${difference.inDays}d";
|
||||
}
|
||||
|
||||
final weeks = difference.inDays ~/ 7;
|
||||
return "${weeks}w";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import "package:path/path.dart" as p;
|
||||
|
||||
String shortenPath(String fullPath, String? projectRoot) {
|
||||
if (projectRoot == null || projectRoot.isEmpty) return fullPath;
|
||||
|
||||
final root = p.normalize(projectRoot);
|
||||
final norm = p.normalize(fullPath);
|
||||
|
||||
if (norm.startsWith(root)) {
|
||||
final rel = norm.substring(root.length);
|
||||
// trim leading separator
|
||||
return rel.startsWith(p.separator) ? rel.substring(1) : rel;
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class AgentsPane extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: OutlinedContainer(
|
||||
width: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import "package:gpt_markdown/gpt_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AdvisorMessage extends StatelessWidget {
|
||||
const AdvisorMessage({super.key, required this.title, required this.body});
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Row(
|
||||
children: [
|
||||
OutlinedContainer(
|
||||
padding: const EdgeInsets.all(10),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
child: Icon(LucideIcons.brain).iconSmall,
|
||||
),
|
||||
Gap(8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.typography.p.copyWith(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (body.isNotEmpty) ...[
|
||||
Gap(8),
|
||||
OutlinedContainer(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: GptMarkdown(body),
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../models/attachment.dart';
|
||||
import '../common/button.dart';
|
||||
|
||||
class AttachmentPreview extends StatelessWidget {
|
||||
final List<Attachment> attachments;
|
||||
final Function(int) onRemove;
|
||||
|
||||
const AttachmentPreview({
|
||||
required this.attachments,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (attachments.isEmpty) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.basic,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < attachments.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: AttachmentItem(
|
||||
attachment: attachments[i],
|
||||
onRemove: () => onRemove(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentItem extends StatelessWidget {
|
||||
final Attachment attachment;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const AttachmentItem({
|
||||
required this.attachment,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
String sanitisedName = attachment.displayName;
|
||||
String type = attachment.mimeType.split("/").last.toUpperCase();
|
||||
|
||||
return OutlinedContainer(
|
||||
height: 52,
|
||||
borderRadius: Theme.of(context).borderRadiusSm,
|
||||
padding: EdgeInsets.all(8),
|
||||
borderColor: Theme.of(context).colorScheme.border,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedContainer(
|
||||
borderRadius: BorderRadius.circular(
|
||||
Theme.of(context).radiusSm - 4
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: _buildPreview(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
Gap(8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
sanitisedName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small.semiBold,
|
||||
Gap(2),
|
||||
Text(
|
||||
type,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).extraLight.small,
|
||||
],
|
||||
),
|
||||
Gap(8),
|
||||
SizedBox(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
Theme.of(context).radiusSm - 4
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AgcGhostButton(
|
||||
onPressed: onRemove,
|
||||
child: Icon(LucideIcons.x, size: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview(BuildContext context) {
|
||||
if (attachment.isImage) {
|
||||
return Image.memory(
|
||||
attachment.data,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
final icon = _getIconForMimeType(attachment.mimeType);
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.muted,
|
||||
child: Icon(icon).iconMedium,
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIconForMimeType(String mimeType) {
|
||||
if (mimeType == 'application/pdf') {
|
||||
return LucideIcons.book;
|
||||
} else if (mimeType.startsWith('text/') || mimeType == 'application/json') {
|
||||
return LucideIcons.fileText;
|
||||
} else if (mimeType.startsWith('image/')) {
|
||||
return LucideIcons.image;
|
||||
} else {
|
||||
return LucideIcons.file;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import "package:gpt_markdown/gpt_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AssistantBubble extends StatelessWidget {
|
||||
const AssistantBubble({super.key, required this.content});
|
||||
|
||||
final String content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GptMarkdown(content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
|
||||
@@ -0,0 +1,89 @@
|
||||
import "dart:convert";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tools/advisor_bubble.dart";
|
||||
import "tools/bash_bubble.dart";
|
||||
import "tools/default_tool_bubble.dart";
|
||||
import "tools/edit_bubble.dart";
|
||||
import "tools/glob_bubble.dart";
|
||||
import "tools/grep_bubble.dart";
|
||||
import "tools/read_bubble.dart";
|
||||
import "tools/web_fetch_bubble.dart";
|
||||
import "tools/web_search_bubble.dart";
|
||||
import "tools/write_bubble.dart";
|
||||
|
||||
class ToolBubble extends StatelessWidget {
|
||||
const ToolBubble({
|
||||
super.key,
|
||||
required this.toolName,
|
||||
this.toolInput,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? toolInput;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
// parse a tool message content string into (toolName, toolInput)
|
||||
// format: "$toolName call\n{json}" or "$toolName result\n..."
|
||||
static (String, Map<String, dynamic>?) parseContent(String content) {
|
||||
final newlineIdx = content.indexOf("\n");
|
||||
if (newlineIdx == -1) {
|
||||
// no body, just a label line
|
||||
final name = _extractName(content);
|
||||
return (name, null);
|
||||
}
|
||||
|
||||
final firstLine = content.substring(0, newlineIdx).trim();
|
||||
final rest = content.substring(newlineIdx + 1).trim();
|
||||
final name = _extractName(firstLine);
|
||||
|
||||
if (firstLine.endsWith(" call") && rest.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(rest);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return (name, decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return (name, null);
|
||||
}
|
||||
|
||||
static String _extractName(String line) {
|
||||
// strip trailing " call" or " result"
|
||||
if (line.endsWith(" call")) return line.substring(0, line.length - 5).trim();
|
||||
if (line.endsWith(" result")) return line.substring(0, line.length - 7).trim();
|
||||
return line.trim();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final input = toolInput ?? {};
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
return BashBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Edit":
|
||||
return EditBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Read":
|
||||
return ReadBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Write":
|
||||
return WriteBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Glob":
|
||||
return GlobBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Grep":
|
||||
return GrepBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "WebSearch":
|
||||
return WebSearchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "WebFetch":
|
||||
return WebFetchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
case "Advisor":
|
||||
return AdvisorBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
|
||||
default:
|
||||
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, isPendingPermission: isPendingPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class AdvisorBubble extends StatelessWidget {
|
||||
const AdvisorBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final model = input["model"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Advisor",
|
||||
icon: LucideIcons.brain,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: model,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class BashBubble extends StatelessWidget {
|
||||
const BashBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final command = input["command"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Bash",
|
||||
icon: LucideIcons.terminal,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: command,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import "dart:convert";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class DefaultToolBubble extends StatelessWidget {
|
||||
const DefaultToolBubble({
|
||||
super.key,
|
||||
required this.toolName,
|
||||
this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: toolName,
|
||||
icon: LucideIcons.wrench,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
body: input != null && input!.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(
|
||||
const JsonEncoder.withIndent(" ").convert(input),
|
||||
style: theme.typography.p.copyWith(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
fontFamily: "monospace",
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "../../diff_view.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class EditBubble extends StatelessWidget {
|
||||
const EditBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final filePath = input["file_path"] as String? ?? "";
|
||||
final oldString = input["old_string"] as String? ?? "";
|
||||
final newString = input["new_string"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Edit",
|
||||
icon: LucideIcons.filePen,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
body: DiffView(
|
||||
oldString: oldString,
|
||||
newString: newString,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class GlobBubble extends StatelessWidget {
|
||||
const GlobBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final pattern = input["pattern"] as String? ?? "";
|
||||
final searchPath = input["path"] as String?;
|
||||
|
||||
final detail = searchPath != null && searchPath.isNotEmpty
|
||||
? "${shortenPath(searchPath, projectRoot)}/$pattern"
|
||||
: pattern;
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Glob",
|
||||
icon: LucideIcons.folderSearch,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class GrepBubble extends StatelessWidget {
|
||||
const GrepBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final pattern = input["pattern"] as String? ?? "";
|
||||
final searchPath = input["path"] as String?;
|
||||
|
||||
final detail = searchPath != null && searchPath.isNotEmpty
|
||||
? "${shortenPath(searchPath, projectRoot)} — $pattern"
|
||||
: pattern;
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Grep",
|
||||
icon: LucideIcons.search,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class ReadBubble extends StatelessWidget {
|
||||
const ReadBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final filePath = input["file_path"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Read",
|
||||
icon: LucideIcons.fileText,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
|
||||
class ToolBubbleBase extends StatelessWidget {
|
||||
const ToolBubbleBase({
|
||||
super.key,
|
||||
required this.toolName,
|
||||
required this.icon,
|
||||
this.detail,
|
||||
this.body,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final IconData icon;
|
||||
final String? detail;
|
||||
final Widget? body;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
|
||||
OutlinedContainer(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
color: theme.colorScheme.primary.scaleAlpha(0.5),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon).iconSmall,
|
||||
|
||||
Gap(8),
|
||||
|
||||
Text(
|
||||
toolName,
|
||||
).textSmall,
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
|
||||
if (detail != null)...[
|
||||
Gap(16),
|
||||
Text(
|
||||
detail!,
|
||||
).mono.xSmall
|
||||
]
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
|
||||
if (body != null) ...[
|
||||
Divider(),
|
||||
|
||||
body!,
|
||||
],
|
||||
|
||||
if (result != null) ...[
|
||||
Divider(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: SelectableText(
|
||||
"\u200B${result!}",
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
).xSmall.mono,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isPendingPermission) ...[
|
||||
|
||||
Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Allow").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
|
||||
child: Text("Allow always").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
|
||||
child: Text("Reject").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WebFetchBubble extends StatelessWidget {
|
||||
const WebFetchBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = input["url"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "WebFetch",
|
||||
icon: LucideIcons.link,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: url,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WebSearchBubble extends StatelessWidget {
|
||||
const WebSearchBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = input["query"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "WebSearch",
|
||||
icon: LucideIcons.globe,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: query,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "../../diff_view.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WriteBubble extends StatelessWidget {
|
||||
const WriteBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectRoot = context.read<ChatProvider>().workingDirectory;
|
||||
final filePath = input["file_path"] as String? ?? "";
|
||||
final content = input["content"] as String? ?? "";
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Write",
|
||||
icon: LucideIcons.filePlus,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
body: DiffView(
|
||||
oldString: "",
|
||||
newString: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class UserBubble extends StatelessWidget {
|
||||
const UserBubble({super.key, required this.content});
|
||||
|
||||
final String content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
backgroundColor: Theme.of(context).colorScheme.border,
|
||||
child: SelectableText(content),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:io';
|
||||
import '../../constants.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/home_coordinator.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import 'attachment_preview.dart';
|
||||
import '../common/button.dart';
|
||||
import 'model_picker_dialog.dart';
|
||||
|
||||
class ChatBox extends StatefulWidget {
|
||||
const ChatBox({super.key});
|
||||
|
||||
@override
|
||||
State<ChatBox> createState() => _ChatBoxState();
|
||||
}
|
||||
|
||||
class _ChatBoxState extends State<ChatBox> {
|
||||
late TextEditingController _controller;
|
||||
late FocusNode _focusNode;
|
||||
final List<Attachment> _attachments = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
_focusNode = FocusNode();
|
||||
_controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
Future<void> _onPastePressed() async {
|
||||
try {
|
||||
final filePaths = await Pasteboard.files();
|
||||
if (filePaths.isNotEmpty && mounted) {
|
||||
for (var filePath in filePaths) {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
final fileBytes = await file.readAsBytes();
|
||||
final fileName = file.path.split('/').last;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_attachments.add(
|
||||
Attachment(
|
||||
name: fileName,
|
||||
mimeType: _getMimeType(fileName, fileBytes),
|
||||
data: fileBytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// skip files that cant be read
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// no files in clipboard
|
||||
}
|
||||
|
||||
// fallback to raw image data (screenshots etc)
|
||||
try {
|
||||
final imageBytes = await Pasteboard.image;
|
||||
if (imageBytes != null && mounted) {
|
||||
final imageData = Uint8List.fromList(imageBytes);
|
||||
setState(() {
|
||||
_attachments.add(
|
||||
Attachment(
|
||||
name: 'image.png',
|
||||
mimeType: _getMimeType('image.png', imageData),
|
||||
data: imageData,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// no image in clipboard
|
||||
}
|
||||
}
|
||||
|
||||
String _getMimeType(String filename, Uint8List data) {
|
||||
if (data.length >= 4) {
|
||||
if (data[0] == 0x25 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x44 &&
|
||||
data[3] == 0x46) {
|
||||
return 'application/pdf';
|
||||
}
|
||||
if (data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4E &&
|
||||
data[3] == 0x47) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (data[0] == 0x52 &&
|
||||
data[1] == 0x49 &&
|
||||
data[2] == 0x46 &&
|
||||
data[3] == 0x46) {
|
||||
if (data.length >= 12 &&
|
||||
data[8] == 0x57 &&
|
||||
data[9] == 0x45 &&
|
||||
data[10] == 0x42 &&
|
||||
data[11] == 0x50) {
|
||||
return 'image/webp';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final extension = filename.split('.').last.toLowerCase();
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return 'application/pdf';
|
||||
case 'txt':
|
||||
return 'text/plain';
|
||||
case 'json':
|
||||
return 'application/json';
|
||||
case 'csv':
|
||||
return 'text/csv';
|
||||
case 'md':
|
||||
return 'text/markdown';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onAttachPressed() async {
|
||||
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
for (final file in result.files) {
|
||||
if (file.path == null) continue;
|
||||
|
||||
try {
|
||||
final f = File(file.path!);
|
||||
final bytes = await f.readAsBytes();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_attachments.add(
|
||||
Attachment(
|
||||
name: file.name,
|
||||
mimeType: _getMimeType(file.name, bytes),
|
||||
data: bytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _left(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 38,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AgcGhostButton(
|
||||
borderRadius: BorderRadius.circular(Theme.of(context).radiusLg - 4),
|
||||
onPressed: _onAttachPressed,
|
||||
child: Icon(LucideIcons.paperclip),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openModelDialog(BuildContext context) async {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final session = context.read<SessionProvider>();
|
||||
final selectedModel = settings.normalizeModelId(settings.settings.model);
|
||||
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => ModelPickerDialog(
|
||||
models: selectableAiModels,
|
||||
selectedModel: selectedModel,
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
await settings.updateModel(result);
|
||||
await session.updateSessionModel(result);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _right(BuildContext context) {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final selectedModel = settings.normalizeModelId(settings.settings.model);
|
||||
|
||||
return SizedBox(
|
||||
height: 38,
|
||||
child: Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 150,
|
||||
minHeight: double.infinity,
|
||||
),
|
||||
child: AgcGhostButton(
|
||||
borderRadius: BorderRadius.circular(
|
||||
Theme.of(context).radiusLg - 4,
|
||||
),
|
||||
onPressed: () => _openModelDialog(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
selectableAiModels
|
||||
.where((m) => m.id == selectedModel)
|
||||
.map((m) => m.label)
|
||||
.firstOrNull ??
|
||||
selectedModel,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small,
|
||||
),
|
||||
Gap(8),
|
||||
Icon(LucideIcons.chevronsUpDown),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: AgcSecondaryButton(
|
||||
enabled: _controller.text.isNotEmpty,
|
||||
onPressed: () {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<HomeCoordinator>().sendMessage(text);
|
||||
_controller.clear();
|
||||
},
|
||||
child: Icon(LucideIcons.arrowUp),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeading(BuildContext context, int numberOfLines) {
|
||||
if (numberOfLines > 1) return SizedBox.shrink();
|
||||
return _left(context);
|
||||
}
|
||||
|
||||
Widget _buildTrailing(int numberOfLines) {
|
||||
if (numberOfLines > 1) return SizedBox.shrink();
|
||||
return _right(context);
|
||||
}
|
||||
|
||||
Widget? _buildBottom(BuildContext context, int numberOfLines) {
|
||||
if (numberOfLines <= 1) return null;
|
||||
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: 8),
|
||||
height: 32,
|
||||
child: Row(children: [_left(context), Spacer(), _right(context)]),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmtTokens(int n) {
|
||||
final s = n.toString();
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
|
||||
buf.write(s[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
void _removeAttachment(int index) {
|
||||
setState(() {
|
||||
_attachments.removeAt(index);
|
||||
});
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onTextChanged);
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chat = context.watch<ChatProvider>();
|
||||
context
|
||||
.watch<SettingsProvider>(); // needed so model label updates reactively
|
||||
|
||||
final queuedMessages = chat.queuedMessages;
|
||||
final contextTokens = chat.contextTokens;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final style = DefaultTextStyle.of(context).style;
|
||||
|
||||
const reservedForIcons = 34;
|
||||
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: _controller.text, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth - reservedForIcons);
|
||||
|
||||
final numberOfLines = painter.computeLineMetrics().length;
|
||||
|
||||
return Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.keyV &&
|
||||
(HardwareKeyboard.instance.isControlPressed ||
|
||||
HardwareKeyboard.instance.isMetaPressed)) {
|
||||
_onPastePressed();
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
final sel = _controller.selection;
|
||||
final text = _controller.text;
|
||||
final newText = text.replaceRange(sel.start, sel.end, '\n');
|
||||
_controller.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: sel.start + 1),
|
||||
);
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
context.read<HomeCoordinator>().sendMessage(text);
|
||||
_controller.clear();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: OutlinedContainer(
|
||||
child: ButtonGroup.vertical(
|
||||
expands: true,
|
||||
children: [
|
||||
for (int i = 0; i < queuedMessages.length; i++) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.cornerDownRight,
|
||||
).iconSmall.iconMutedForeground,
|
||||
|
||||
Gap(14),
|
||||
|
||||
Expanded(
|
||||
child: Text(queuedMessages[i]).small.textMuted,
|
||||
),
|
||||
|
||||
IconButton.text(
|
||||
onPressed: () => chat.removeQueuedMessage(i),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
).iconSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
],
|
||||
|
||||
TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
borderRadius: Theme.of(context).borderRadiusLg,
|
||||
placeholder: Text("Ask the agency anything"),
|
||||
minLines: 1,
|
||||
maxLines: numberOfLines > 1 ? 5 : 1,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
padding: EdgeInsets.all(8),
|
||||
features: [
|
||||
if (_attachments.isNotEmpty)
|
||||
InputFeature.above(
|
||||
AttachmentPreview(
|
||||
attachments: _attachments,
|
||||
onRemove: _removeAttachment,
|
||||
),
|
||||
),
|
||||
|
||||
InputFeature.leading(
|
||||
_buildLeading(context, numberOfLines),
|
||||
),
|
||||
|
||||
InputFeature.trailing(_buildTrailing(numberOfLines)),
|
||||
|
||||
InputFeature.below(
|
||||
_buildBottom(context, numberOfLines),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (chat.isLoading)
|
||||
SizedBox(
|
||||
height: 4,
|
||||
child: LinearProgressIndicator()
|
||||
)
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../../src/session/session_types.dart";
|
||||
import "../../providers/chat_provider.dart";
|
||||
import "bubbles/assistant_bubble.dart";
|
||||
import "bubbles/tool_bubble.dart";
|
||||
import "bubbles/user_bubble.dart";
|
||||
|
||||
class ChatView extends StatefulWidget {
|
||||
final ScrollController scrollController;
|
||||
|
||||
const ChatView({super.key, required this.scrollController});
|
||||
|
||||
@override
|
||||
State<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
ScrollController get _scrollController => widget.scrollController;
|
||||
List<String> _previousMessageContents = [];
|
||||
bool _isUserScrolling = false;
|
||||
DateTime? _lastScrollTime;
|
||||
bool _showJumpToBottom = false;
|
||||
bool _hasNewMessagesWhileScrolledAway = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
_lastScrollTime = DateTime.now();
|
||||
_isUserScrolling = true;
|
||||
|
||||
if (_scrollController.hasClients) {
|
||||
final position = _scrollController.position;
|
||||
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
|
||||
if (isFarFromBottom != _showJumpToBottom) {
|
||||
setState(() {
|
||||
_showJumpToBottom = isFarFromBottom;
|
||||
});
|
||||
}
|
||||
|
||||
if (!isFarFromBottom) {
|
||||
setState(() {
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (_lastScrollTime != null &&
|
||||
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUserScrolling = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isNearBottom() {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
final position = _scrollController.position;
|
||||
return position.pixels >= position.maxScrollExtent - 150;
|
||||
}
|
||||
|
||||
void _jumpToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
setState(() {
|
||||
_showJumpToBottom = false;
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ChatProvider>(
|
||||
builder: (context, chatProvider, _) {
|
||||
final currentMessages = chatProvider.messages;
|
||||
|
||||
bool messagesChanged = false;
|
||||
if (currentMessages.length != _previousMessageContents.length) {
|
||||
messagesChanged = true;
|
||||
} else {
|
||||
for (int i = 0; i < currentMessages.length; i++) {
|
||||
if (currentMessages[i].content != _previousMessageContents[i]) {
|
||||
messagesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesChanged && currentMessages.isNotEmpty) {
|
||||
final nearBottom = _isNearBottom();
|
||||
|
||||
if (nearBottom && !_isUserScrolling) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
} else if (!nearBottom) {
|
||||
_hasNewMessagesWhileScrolledAway = true;
|
||||
}
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_previousMessageContents = currentMessages.map((m) => m.content).toList();
|
||||
});
|
||||
|
||||
final entries = _buildEntries(currentMessages);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final pending = chatProvider.pendingPermission;
|
||||
|
||||
final isThisPending = pending != null &&
|
||||
index == entries.length - 1 &&
|
||||
entry is _ToolEntry &&
|
||||
entry.toolName == pending.toolName;
|
||||
|
||||
Widget bubble;
|
||||
if (entry is _MessageEntry) {
|
||||
final msg = entry.message;
|
||||
if (msg.role == "user") {
|
||||
bubble = UserBubble(content: msg.content);
|
||||
} else if (msg.role == "assistant") {
|
||||
bubble = AssistantBubble(content: msg.content);
|
||||
} else {
|
||||
bubble = Text(msg.content);
|
||||
}
|
||||
} else if (entry is _ToolEntry) {
|
||||
bubble = ToolBubble(
|
||||
toolName: entry.toolName,
|
||||
toolInput: entry.toolInput,
|
||||
result: entry.result,
|
||||
isPendingPermission: isThisPending,
|
||||
);
|
||||
} else {
|
||||
bubble = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: bubble,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: GestureDetector(
|
||||
onTap: _jumpToBottom,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF000000).withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowDown,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"New messages",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// merge consecutive tool call + result messages into single entries
|
||||
List<_ChatEntry> _buildEntries(List<Message> messages) {
|
||||
final result = <_ChatEntry>[];
|
||||
int i = 0;
|
||||
while (i < messages.length) {
|
||||
final msg = messages[i];
|
||||
if (msg.role == "tool") {
|
||||
final firstLine = msg.content.split("\n").first.trim();
|
||||
|
||||
if (firstLine.endsWith(" call")) {
|
||||
final (toolName, toolInput) = ToolBubble.parseContent(msg.content);
|
||||
|
||||
// check if next message is the matching result
|
||||
String? toolResult;
|
||||
if (i + 1 < messages.length) {
|
||||
final next = messages[i + 1];
|
||||
final nextFirst = next.content.split("\n").first.trim();
|
||||
if (next.role == "tool" && nextFirst == "$toolName result") {
|
||||
final body = next.content.indexOf("\n");
|
||||
toolResult = body != -1 ? next.content.substring(body + 1).trim() : null;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
result.add(_ToolEntry(
|
||||
toolName: toolName,
|
||||
toolInput: toolInput,
|
||||
result: toolResult,
|
||||
));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// orphan result or unknown tool message — skip it
|
||||
// (already consumed as part of a call above, or genuinely standalone)
|
||||
final (toolName, _) = ToolBubble.parseContent(msg.content);
|
||||
result.add(_ToolEntry(toolName: toolName));
|
||||
i++;
|
||||
} else {
|
||||
result.add(_MessageEntry(msg));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class _ChatEntry {}
|
||||
|
||||
class _MessageEntry extends _ChatEntry {
|
||||
_MessageEntry(this.message);
|
||||
final Message message;
|
||||
}
|
||||
|
||||
class _ToolEntry extends _ChatEntry {
|
||||
_ToolEntry({required this.toolName, this.toolInput, this.result});
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? toolInput;
|
||||
final String? result;
|
||||
}
|
||||
|
||||
|
||||
class FullHeightScrollbar extends StatefulWidget {
|
||||
final ScrollController controller;
|
||||
|
||||
const FullHeightScrollbar({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<FullHeightScrollbar> createState() => _FullHeightScrollbarState();
|
||||
}
|
||||
|
||||
class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
|
||||
bool _hovering = false;
|
||||
bool _scrolling = false;
|
||||
DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onScroll);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
_lastScroll = DateTime.now();
|
||||
setState(() => _scrolling = true);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (!mounted) return;
|
||||
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) {
|
||||
setState(() => _scrolling = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onScroll);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visible = _hovering || _scrolling;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() => _hovering = false),
|
||||
child: AnimatedOpacity(
|
||||
opacity: visible ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final totalHeight = constraints.maxHeight;
|
||||
|
||||
if (!widget.controller.hasClients) return const SizedBox.shrink();
|
||||
|
||||
final pos = widget.controller.position;
|
||||
final maxScroll = pos.maxScrollExtent;
|
||||
|
||||
if (maxScroll <= 0) return const SizedBox.shrink();
|
||||
|
||||
final viewportFraction = pos.viewportDimension / (pos.viewportDimension + maxScroll);
|
||||
final thumbHeight = (viewportFraction * totalHeight).clamp(32.0, totalHeight);
|
||||
final scrollFraction = pos.pixels / maxScroll;
|
||||
final thumbTop = scrollFraction * (totalHeight - thumbHeight);
|
||||
|
||||
final color = Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.4);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: thumbTop,
|
||||
left: 2,
|
||||
right: 2,
|
||||
height: thumbHeight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import "package:diff_match_patch/diff_match_patch.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
const _contextLines = 3;
|
||||
|
||||
class DiffView extends StatelessWidget {
|
||||
const DiffView({
|
||||
super.key,
|
||||
this.oldString,
|
||||
this.newString,
|
||||
this.content,
|
||||
}) : assert(
|
||||
content != null || (oldString != null && newString != null),
|
||||
"Provide either content (view-only) or oldString+newString (diff)",
|
||||
);
|
||||
|
||||
final String? oldString;
|
||||
final String? newString;
|
||||
|
||||
// view-only mode — show content as plain code, no diff colors
|
||||
final String? content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (content != null) {
|
||||
final lines = content!.split("\n");
|
||||
final viewLines = [
|
||||
for (int i = 0; i < lines.length; i++)
|
||||
_DiffLine(_LineKind.context, lines[i], newLine: i + 1),
|
||||
];
|
||||
final hunk = _Hunk(oldStart: 1, newStart: 1, lines: viewLines);
|
||||
return _HunkView(hunk: hunk);
|
||||
}
|
||||
|
||||
final hunks = _computeHunks(oldString!, newString!);
|
||||
|
||||
if (hunks.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final hunk in hunks) ...[
|
||||
|
||||
// is first
|
||||
if (hunk != hunks.first) ...[
|
||||
Divider(),
|
||||
Gap(1),
|
||||
Divider(),
|
||||
],
|
||||
|
||||
_HunkView(hunk: hunk)
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
enum _LineKind { context, added, removed }
|
||||
|
||||
class _DiffLine {
|
||||
const _DiffLine(this.kind, this.text, {this.oldLine, this.newLine});
|
||||
final _LineKind kind;
|
||||
final String text;
|
||||
final int? oldLine;
|
||||
final int? newLine;
|
||||
}
|
||||
|
||||
class _Hunk {
|
||||
_Hunk({
|
||||
required this.oldStart,
|
||||
required this.newStart,
|
||||
required this.lines,
|
||||
});
|
||||
final int oldStart;
|
||||
final int newStart;
|
||||
final List<_DiffLine> lines;
|
||||
|
||||
int get oldCount => lines.where((l) => l.kind != _LineKind.added).length;
|
||||
int get newCount => lines.where((l) => l.kind != _LineKind.removed).length;
|
||||
}
|
||||
|
||||
// ─── diff computation ─────────────────────────────────────────────────────────
|
||||
|
||||
List<_Hunk> _computeHunks(String oldStr, String newStr) {
|
||||
final dmp = DiffMatchPatch();
|
||||
|
||||
final oldLines = oldStr.split("\n");
|
||||
final newLines = newStr.split("\n");
|
||||
|
||||
// encode lines → single chars so dmp does line-level diff
|
||||
final enc = _encodeLines(oldLines, newLines);
|
||||
final diffs = dmp.diff(enc.oldEncoded, enc.newEncoded, false);
|
||||
dmp.diffCleanupSemantic(diffs);
|
||||
|
||||
// expand diffs back to line sequences
|
||||
final rawLines = <_DiffLine>[];
|
||||
int oldIdx = 0;
|
||||
int newIdx = 0;
|
||||
|
||||
for (final d in diffs) {
|
||||
final count = d.text.length; // each char == one line
|
||||
switch (d.operation) {
|
||||
case DIFF_EQUAL:
|
||||
for (int i = 0; i < count; i++) {
|
||||
rawLines.add(_DiffLine(
|
||||
_LineKind.context,
|
||||
enc.lines[d.text.codeUnitAt(i) - 0xE000],
|
||||
oldLine: oldIdx + 1,
|
||||
newLine: newIdx + 1,
|
||||
));
|
||||
oldIdx++;
|
||||
newIdx++;
|
||||
}
|
||||
break;
|
||||
|
||||
case DIFF_DELETE:
|
||||
for (int i = 0; i < count; i++) {
|
||||
rawLines.add(_DiffLine(
|
||||
_LineKind.removed,
|
||||
enc.lines[d.text.codeUnitAt(i) - 0xE000],
|
||||
oldLine: oldIdx + 1,
|
||||
));
|
||||
oldIdx++;
|
||||
}
|
||||
break;
|
||||
|
||||
case DIFF_INSERT:
|
||||
for (int i = 0; i < count; i++) {
|
||||
rawLines.add(_DiffLine(
|
||||
_LineKind.added,
|
||||
enc.lines[d.text.codeUnitAt(i) - 0xE000],
|
||||
newLine: newIdx + 1,
|
||||
));
|
||||
newIdx++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return _groupIntoHunks(rawLines);
|
||||
}
|
||||
|
||||
// keep only context lines that are within _contextLines of a change
|
||||
List<_Hunk> _groupIntoHunks(List<_DiffLine> rawLines) {
|
||||
final n = rawLines.length;
|
||||
|
||||
// mark which context lines to keep
|
||||
final keep = List<bool>.filled(n, false);
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (rawLines[i].kind != _LineKind.context) {
|
||||
for (int j = (i - _contextLines).clamp(0, n - 1);
|
||||
j <= (i + _contextLines).clamp(0, n - 1);
|
||||
j++) {
|
||||
keep[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final hunks = <_Hunk>[];
|
||||
int i = 0;
|
||||
|
||||
while (i < n) {
|
||||
if (!keep[i]) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// start of a new hunk
|
||||
final hunkLines = <_DiffLine>[];
|
||||
int oldStart = rawLines[i].oldLine ?? 1;
|
||||
int newStart = rawLines[i].newLine ?? 1;
|
||||
|
||||
while (i < n && keep[i]) {
|
||||
hunkLines.add(rawLines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
hunks.add(_Hunk(
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
lines: hunkLines,
|
||||
));
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
// line encoding — maps unique lines to single unicode chars starting at U+E000
|
||||
class _LineEncoding {
|
||||
final List<String> lines; // index → line text
|
||||
final String oldEncoded;
|
||||
final String newEncoded;
|
||||
const _LineEncoding(this.lines, this.oldEncoded, this.newEncoded);
|
||||
}
|
||||
|
||||
_LineEncoding _encodeLines(List<String> oldLines, List<String> newLines) {
|
||||
final lineIndex = <String, int>{};
|
||||
final lines = <String>[];
|
||||
|
||||
String encode(List<String> src) {
|
||||
final buf = StringBuffer();
|
||||
for (final line in src) {
|
||||
if (!lineIndex.containsKey(line)) {
|
||||
lineIndex[line] = lines.length;
|
||||
lines.add(line);
|
||||
}
|
||||
buf.writeCharCode(0xE000 + lineIndex[line]!);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
final oldEncoded = encode(oldLines);
|
||||
final newEncoded = encode(newLines);
|
||||
return _LineEncoding(lines, oldEncoded, newEncoded);
|
||||
}
|
||||
|
||||
// ─── widgets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
String _hunkSummary(_Hunk hunk) {
|
||||
final added = hunk.lines.where((l) => l.kind == _LineKind.added).length;
|
||||
final removed = hunk.lines.where((l) => l.kind == _LineKind.removed).length;
|
||||
|
||||
final parts = <String>[];
|
||||
if (added > 0) parts.add("Added $added ${added == 1 ? 'line' : 'lines'}");
|
||||
if (removed > 0) parts.add("removed $removed ${removed == 1 ? 'line' : 'lines'}");
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
class _HunkView extends StatelessWidget {
|
||||
const _HunkView({required this.hunk});
|
||||
final _Hunk hunk;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
// hunk header
|
||||
Container(
|
||||
// color: theme.colorScheme.muted.withValues(alpha: 0.4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Text(
|
||||
_hunkSummary(hunk),
|
||||
style: TextStyle(color: theme.colorScheme.mutedForeground),
|
||||
).xSmall.mono,
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
for (final line in hunk.lines) _LineView(line: line),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LineView extends StatelessWidget {
|
||||
const _LineView({required this.line});
|
||||
final _DiffLine line;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
final String prefix;
|
||||
|
||||
switch (line.kind) {
|
||||
case _LineKind.added:
|
||||
bg = const Color(0xFF166534).withValues(alpha: 0.2);
|
||||
fg = const Color(0xFF4ADE80);
|
||||
prefix = "+";
|
||||
break;
|
||||
case _LineKind.removed:
|
||||
bg = const Color(0xFF991B1B).withValues(alpha: 0.2);
|
||||
fg = const Color(0xFFF87171);
|
||||
prefix = "-";
|
||||
break;
|
||||
case _LineKind.context:
|
||||
bg = Colors.transparent;
|
||||
fg = theme.colorScheme.mutedForeground;
|
||||
prefix = " ";
|
||||
break;
|
||||
}
|
||||
|
||||
final numColor = theme.colorScheme.mutedForeground.withValues(alpha: 0.5);
|
||||
final lineNum = line.kind == _LineKind.removed ? line.oldLine : line.newLine;
|
||||
|
||||
return Container(
|
||||
color: bg,
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Text(
|
||||
lineNum != null ? "$lineNum" : "",
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(color: numColor),
|
||||
).mono.xSmall,
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
SizedBox(
|
||||
width: 10,
|
||||
child: Text(prefix, style: TextStyle(color: fg)).mono.xSmall,
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
|
||||
Expanded(
|
||||
child: Text(line.text, style: TextStyle(color: fg)).mono.xSmall,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,21 @@ import "package:flutter/src/material/theme_data.dart";
|
||||
import "package:flutter_markdown/flutter_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../src/session/session_types.dart";
|
||||
import "../../../src/permissions/permission_types.dart";
|
||||
import "../../../src/session/session_types.dart";
|
||||
import "advisor_message.dart";
|
||||
import "../common/button.dart";
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({required this.message});
|
||||
const MessageBubble({
|
||||
required this.message,
|
||||
this.isPendingPermission = false,
|
||||
this.onPermissionDecision,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final bool isPendingPermission;
|
||||
final void Function(PermissionDecision)? onPermissionDecision;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -19,23 +28,21 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
|
||||
if (isUser) {
|
||||
return Row(
|
||||
children: [
|
||||
Spacer(),
|
||||
OutlinedContainer(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
backgroundColor: theme.colorScheme.border,
|
||||
child: MarkdownBody(
|
||||
data: message.content,
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
styleSheet: _toolMarkdownStyleSheet(context),
|
||||
),
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedContainer(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
],
|
||||
backgroundColor: theme.colorScheme.border,
|
||||
child: MarkdownBody(
|
||||
data: message.content,
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
styleSheet: _toolMarkdownStyleSheet(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (isAssistant) {
|
||||
return MarkdownBody(
|
||||
@@ -48,27 +55,66 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
final lines = message.content.split("\n");
|
||||
final title = lines.first.trim();
|
||||
final isAdvisor = title.startsWith("Advisor");
|
||||
|
||||
return Row(
|
||||
if (isAdvisor) {
|
||||
final body = lines.skip(1).join("\n").trim();
|
||||
return AdvisorMessage(title: title, body: body);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 10,
|
||||
width: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
OutlinedContainer(
|
||||
padding: const EdgeInsets.all(10),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
child: Icon(LucideIcons.wrench).iconSmall,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.typography.p.copyWith(fontSize: 13),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
if (isPendingPermission) ...[
|
||||
Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Text(
|
||||
title,
|
||||
style: theme.typography.p.copyWith(
|
||||
fontSize: 13
|
||||
AgcSecondaryButton(
|
||||
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowOnce),
|
||||
child: Text("Allow").small,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
AgcGhostButton(
|
||||
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowAlways),
|
||||
child: Text("Allow always").small,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
AgcGhostButton(
|
||||
onPressed: () => onPermissionDecision?.call(PermissionDecision.reject),
|
||||
child: Text("Reject").small,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
import "../../src/api/openrouter_client.dart";
|
||||
import "../providers/settings_provider.dart";
|
||||
import "../../../src/api/openrouter_client.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
|
||||
class ModelPicker extends StatefulWidget {
|
||||
const ModelPicker();
|
||||
@@ -0,0 +1,103 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../constants.dart";
|
||||
|
||||
class ModelPickerDialog extends StatefulWidget {
|
||||
|
||||
final List<SelectableAiModel> models;
|
||||
final String? selectedModel;
|
||||
|
||||
const ModelPickerDialog({
|
||||
super.key,
|
||||
required this.models,
|
||||
this.selectedModel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ModelPickerDialog> createState() => _ModelPickerDialogState();
|
||||
}
|
||||
|
||||
class _ModelPickerDialogState extends State<ModelPickerDialog> {
|
||||
late TextEditingController _searchController;
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController();
|
||||
_searchController.addListener(() {
|
||||
setState(() => _query = _searchController.text.trim().toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<SelectableAiModel> get _filtered {
|
||||
if (_query.isEmpty) return widget.models;
|
||||
return widget.models.where((m) =>
|
||||
m.label.toLowerCase().contains(_query) ||
|
||||
m.id.toLowerCase().contains(_query)
|
||||
).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filtered = _filtered;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Select model'),
|
||||
content: SizedBox(
|
||||
width: 340,
|
||||
height: 380,
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
placeholder: const Text('Search models...'),
|
||||
features: const [InputFeature.clear()],
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: filtered.isEmpty
|
||||
? Center(child: Text("No results").muted)
|
||||
: ListView.builder(
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, i) {
|
||||
final model = filtered[i];
|
||||
final isSelected = model.id == widget.selectedModel;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
|
||||
disableFocusOutline: true,
|
||||
onPressed: () => Navigator.of(context).pop(model.id),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(model.label),
|
||||
Text(model.id).muted.small,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/chat_provider.dart";
|
||||
import "message_bubble.dart";
|
||||
|
||||
class ChatView extends StatefulWidget {
|
||||
const ChatView();
|
||||
|
||||
@override
|
||||
State<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
late ScrollController _scrollController;
|
||||
List<String> _previousMessageContents = [];
|
||||
bool _isUserScrolling = false;
|
||||
DateTime? _lastScrollTime;
|
||||
bool _showJumpToBottom = false;
|
||||
bool _hasNewMessagesWhileScrolledAway = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
_lastScrollTime = DateTime.now();
|
||||
_isUserScrolling = true;
|
||||
|
||||
// Update whether to show jump-to-bottom button
|
||||
if (_scrollController.hasClients) {
|
||||
final position = _scrollController.position;
|
||||
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
|
||||
if (isFarFromBottom != _showJumpToBottom) {
|
||||
setState(() {
|
||||
_showJumpToBottom = isFarFromBottom;
|
||||
});
|
||||
}
|
||||
|
||||
// If user scrolls to bottom manually, clear the new messages flag
|
||||
if (!isFarFromBottom) {
|
||||
setState(() {
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if scrolling has stopped (no scroll events for 150ms)
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (_lastScrollTime != null &&
|
||||
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUserScrolling = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isNearBottom() {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
|
||||
final position = _scrollController.position;
|
||||
// Consider user to be "near bottom" if they're within 150 pixels of the bottom
|
||||
// Add a small buffer so we don't trigger on exact bottom
|
||||
return position.pixels >= position.maxScrollExtent - 150;
|
||||
}
|
||||
|
||||
void _jumpToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
setState(() {
|
||||
_showJumpToBottom = false;
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ChatProvider>(
|
||||
builder: (context, chatProvider, _) {
|
||||
// Get current messages
|
||||
final currentMessages = chatProvider.messages;
|
||||
|
||||
// Check if messages have actually changed (not just re-renders)
|
||||
bool messagesChanged = false;
|
||||
|
||||
if (currentMessages.length != _previousMessageContents.length) {
|
||||
messagesChanged = true;
|
||||
} else {
|
||||
for (int i = 0; i < currentMessages.length; i++) {
|
||||
if (i >= _previousMessageContents.length ||
|
||||
currentMessages[i].content != _previousMessageContents[i]) {
|
||||
messagesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesChanged && currentMessages.isNotEmpty) {
|
||||
// Check if we're near the bottom
|
||||
final nearBottom = _isNearBottom();
|
||||
|
||||
if (nearBottom && !_isUserScrolling) {
|
||||
// Auto-scroll to bottom if user is near bottom and not scrolling
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
} else if (!nearBottom) {
|
||||
// User is scrolled away from bottom when new messages arrive
|
||||
_hasNewMessagesWhileScrolledAway = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update previous message state for next build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_previousMessageContents = currentMessages.map((m) => m.content).toList();
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: currentMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = currentMessages[index];
|
||||
return Padding(
|
||||
padding: EdgeInsetsGeometry.only(
|
||||
top: index != 0 ? 12 : 0
|
||||
),
|
||||
child: MessageBubble(message: message)
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: GestureDetector(
|
||||
onTap: _jumpToBottom,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF000000).withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowDown,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"New messages",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AgcGhostButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
AgcGhostButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcGhostButton> createState() => _GhostButtonState();
|
||||
}
|
||||
|
||||
class _GhostButtonState extends State<AgcGhostButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm - 4
|
||||
);
|
||||
|
||||
Color bg = Colors.transparent;
|
||||
if (_pressing) {
|
||||
bg = colorScheme.accent.withOpacity(0.8);
|
||||
} else if (_hovering) {
|
||||
bg = colorScheme.accent.withOpacity(0.5);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: widget.onPressed != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) => setState(() => _pressing = true),
|
||||
onTapUp: (_) {
|
||||
setState(() => _pressing = false);
|
||||
if (widget.onPressed != null) widget.onPressed!();
|
||||
},
|
||||
onTapCancel: () => setState(() => _pressing = false),
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
),
|
||||
padding: EdgeInsets.all(4),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgcSecondaryButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
final bool enabled;
|
||||
|
||||
AgcSecondaryButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcSecondaryButton> createState() => _SecondaryButtonState();
|
||||
}
|
||||
|
||||
class _SecondaryButtonState extends State<AgcSecondaryButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm
|
||||
);
|
||||
|
||||
final bool active = widget.enabled && widget.onPressed != null;
|
||||
|
||||
Color bg = colorScheme.secondary;
|
||||
if (!active) {
|
||||
bg = colorScheme.secondary.withOpacity(0.4);
|
||||
} else if (_pressing) {
|
||||
bg = colorScheme.secondary.withOpacity(0.75);
|
||||
} else if (_hovering) {
|
||||
bg = colorScheme.secondary.withOpacity(0.85);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) { if (active) setState(() => _hovering = true); },
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
|
||||
onTapUp: active ? (_) {
|
||||
setState(() => _pressing = false);
|
||||
widget.onPressed!();
|
||||
} : null,
|
||||
onTapCancel: active ? () => setState(() => _pressing = false) : null,
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
),
|
||||
padding: EdgeInsets.all(4),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(color: colorScheme.secondaryForeground),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(color: colorScheme.secondaryForeground),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgcOutlinedButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
final bool enabled;
|
||||
|
||||
AgcOutlinedButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcOutlinedButton> createState() => _OutlinedButtonState();
|
||||
}
|
||||
|
||||
class _OutlinedButtonState extends State<AgcOutlinedButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm
|
||||
);
|
||||
|
||||
final bool active = widget.enabled && widget.onPressed != null;
|
||||
|
||||
Color bg = Colors.transparent;
|
||||
if (_pressing && active) {
|
||||
bg = colorScheme.accent.withOpacity(0.6);
|
||||
} else if (_hovering && active) {
|
||||
bg = colorScheme.accent.withOpacity(0.35);
|
||||
}
|
||||
|
||||
final borderColor = active
|
||||
? colorScheme.border
|
||||
: colorScheme.border.withOpacity(0.4);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) { if (active) setState(() => _hovering = true); },
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
|
||||
onTapUp: active ? (_) {
|
||||
setState(() => _pressing = false);
|
||||
widget.onPressed!();
|
||||
} : null,
|
||||
onTapCancel: active ? () => setState(() => _pressing = false) : null,
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(
|
||||
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import "package:flutter/widgets.dart" hide Tooltip;
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart" hide Row, Expanded;
|
||||
|
||||
import "../../providers/chat_provider.dart";
|
||||
import "../../providers/cost_provider.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
|
||||
|
||||
String _fmtTokens(int n) {
|
||||
final s = n.toString();
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
|
||||
buf.write(s[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
|
||||
class FooterBar extends StatelessWidget {
|
||||
const FooterBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mutedFg = theme.colorScheme.mutedForeground;
|
||||
final borderColor = theme.colorScheme.border;
|
||||
final bg = theme.colorScheme.muted.scaleAlpha(0.3);
|
||||
|
||||
final costProvider = context.watch<CostProvider>();
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
|
||||
final model = settingsProvider.settings.model ?? "unknown";
|
||||
final costUsd = costProvider.getTotalCostUsd();
|
||||
final cost = "\$${costUsd.toStringAsFixed(4)}";
|
||||
final inputToks = costProvider.getTotalInputTokens();
|
||||
final outputToks = costProvider.getTotalOutputTokens();
|
||||
final isLoading = chatProvider.isLoading;
|
||||
final contextTokens = chatProvider.contextTokens;
|
||||
|
||||
final textStyle = TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: mutedFg,
|
||||
);
|
||||
|
||||
Widget divider() => const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 5),
|
||||
child: SizedBox(height: 12, child: VerticalDivider(width: 1)),
|
||||
);
|
||||
|
||||
Widget copyrightBlock() {
|
||||
return Text(
|
||||
"© 2026 IMBENJI.NET LTD - The Agency",
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
|
||||
Widget statusBlock() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
isLoading ? "running..." : "idle",
|
||||
style: textStyle.copyWith(
|
||||
color: isLoading
|
||||
? theme.colorScheme.primary
|
||||
: mutedFg,
|
||||
),
|
||||
),
|
||||
|
||||
divider(),
|
||||
|
||||
Text(model.split("/").last, style: textStyle),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget statsBlock() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
if (contextTokens > 0) ...[
|
||||
Text(_fmtTokens(contextTokens), style: textStyle),
|
||||
Text(" tokens", style: textStyle),
|
||||
divider(),
|
||||
],
|
||||
|
||||
Tooltip(
|
||||
tooltip: (_) => TooltipContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Text(
|
||||
"In: $inputToks\nOut: $outputToks",
|
||||
style: const TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(cost, style: textStyle),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border(top: BorderSide(color: borderColor, width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Expanded(child: Row(children: [copyrightBlock()])),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [statusBlock()],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [statsBlock()],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/settings_provider.dart";
|
||||
import "model_picker.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "../chat/model_picker.dart";
|
||||
|
||||
class SettingsSheet extends StatelessWidget {
|
||||
const SettingsSheet();
|
||||
@@ -0,0 +1,32 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AppLogo extends StatelessWidget {
|
||||
const AppLogo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"THE AGENCY",
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../utils/format_relative_time.dart';
|
||||
|
||||
class ProjectButton extends StatefulWidget {
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final DateTime? lastMessage;
|
||||
final bool collapsed;
|
||||
|
||||
ProjectButton({
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.lastMessage,
|
||||
this.collapsed = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProjectButton> createState() => ProjectButtonState();
|
||||
}
|
||||
|
||||
class ProjectButtonState extends State<ProjectButton> with TickerProviderStateMixin {
|
||||
|
||||
bool _isHovering = false;
|
||||
late AnimationController _chevronController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chevronController = AnimationController(
|
||||
duration: Duration(milliseconds: 100),
|
||||
vsync: this,
|
||||
);
|
||||
if (!widget.collapsed) {
|
||||
_chevronController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ProjectButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.collapsed != widget.collapsed) {
|
||||
if (widget.collapsed) {
|
||||
_chevronController.reverse();
|
||||
} else {
|
||||
_chevronController.forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chevronController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 12,
|
||||
bottom: 8,
|
||||
right: 12
|
||||
);
|
||||
}
|
||||
),
|
||||
disableFocusOutline: true,
|
||||
onPressed: () {
|
||||
if (widget.onPressed != null) {
|
||||
widget.onPressed!();
|
||||
}
|
||||
},
|
||||
onHover: (isHovering) {
|
||||
setState(() {
|
||||
_isHovering = isHovering;
|
||||
});
|
||||
},
|
||||
leading: !_isHovering ? Icon(
|
||||
!widget.collapsed ? LucideIcons.folderOpen : LucideIcons.folderClosed
|
||||
).iconSmall : RotationTransition(
|
||||
turns: Tween(begin: 0.0, end: 0.25).animate(_chevronController),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: colorScheme.mutedForeground,
|
||||
).iconSmall,
|
||||
),
|
||||
trailingGap: 32,
|
||||
trailing: widget.lastMessage != null ?
|
||||
Text(
|
||||
formatRelativeTime(widget.lastMessage!)
|
||||
).muted : null,
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.mutedForeground
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small,
|
||||
),
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import 'project_button.dart';
|
||||
|
||||
class ProjectSection extends StatefulWidget {
|
||||
|
||||
final String projectLabel;
|
||||
final List<Widget> children;
|
||||
final VoidCallback? onHeaderPressed;
|
||||
|
||||
ProjectSection({
|
||||
required this.projectLabel,
|
||||
this.children = const [],
|
||||
this.onHeaderPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProjectSection> createState() => ProjectSectionState();
|
||||
}
|
||||
|
||||
class ProjectSectionState extends State<ProjectSection> with TickerProviderStateMixin {
|
||||
|
||||
bool _isCollapsed = true;
|
||||
late AnimationController _sizeController;
|
||||
late AnimationController _fadeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sizeController = AnimationController(
|
||||
duration: Duration(milliseconds: 150),
|
||||
vsync: this,
|
||||
);
|
||||
_fadeController = AnimationController(
|
||||
duration: Duration(milliseconds: 250),
|
||||
vsync: this,
|
||||
);
|
||||
if (!_isCollapsed) {
|
||||
_sizeController.forward();
|
||||
_fadeController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sizeController.dispose();
|
||||
_fadeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
ProjectButton(
|
||||
label: widget.projectLabel,
|
||||
collapsed: _isCollapsed,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isCollapsed = !_isCollapsed;
|
||||
if (_isCollapsed) {
|
||||
_fadeController.reverse();
|
||||
_sizeController.reverse();
|
||||
} else {
|
||||
_fadeController.forward();
|
||||
_sizeController.forward();
|
||||
}
|
||||
});
|
||||
widget.onHeaderPressed?.call();
|
||||
},
|
||||
),
|
||||
|
||||
Gap(2),
|
||||
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeController,
|
||||
child: SizeTransition(
|
||||
sizeFactor: _sizeController,
|
||||
child: Column(
|
||||
spacing: 2,
|
||||
children: [
|
||||
|
||||
...widget.children
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../../src/session/session_types.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/home_coordinator.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import 'app_logo.dart';
|
||||
import 'project_section.dart';
|
||||
import 'thread_button.dart';
|
||||
|
||||
class Sidebar extends StatelessWidget {
|
||||
|
||||
const Sidebar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
return Container(
|
||||
width: 300,
|
||||
color: Theme.of(context).colorScheme.input.scaleAlpha(0.3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 100,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppLogo(),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _fixedSection(context, coordinator, chatProvider),
|
||||
),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fixedSection(BuildContext context, HomeCoordinator coordinator, ChatProvider chatProvider) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
right: 10
|
||||
);
|
||||
}
|
||||
),
|
||||
onPressed: chatProvider.isLoading ? null : coordinator.createNewChat,
|
||||
disableFocusOutline: true,
|
||||
leading: Icon(LucideIcons.squarePen).iconSmall,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("New Chat"),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
right: 10
|
||||
);
|
||||
}
|
||||
),
|
||||
onPressed: coordinator.pickProjectDirectory,
|
||||
disableFocusOutline: true,
|
||||
leading: Icon(LucideIcons.folderPlus).iconSmall,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("New Project"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator) {
|
||||
if (projectsProvider.projects.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Text("No projects yet").textSmall.muted,
|
||||
);
|
||||
}
|
||||
|
||||
// group sessions by working directory
|
||||
final sessionsByProject = <String, List<SessionSummary>>{};
|
||||
for (final session in sessionProvider.sessions) {
|
||||
final dir = session.workingDirectory ?? '';
|
||||
sessionsByProject.putIfAbsent(dir, () => []).add(session);
|
||||
}
|
||||
|
||||
// sort sessions within each project newest first
|
||||
final sorted = <String, List<SessionSummary>>{};
|
||||
sessionsByProject.forEach((dir, sessions) {
|
||||
sorted[dir] = List<SessionSummary>.from(sessions)
|
||||
..sort((a, b) => b.updated.compareTo(a.updated));
|
||||
});
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text("Projects").textMuted,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
for (final project in projectsProvider.projects) ...[
|
||||
|
||||
ProjectSection(
|
||||
projectLabel: project.name,
|
||||
children: [
|
||||
if (sorted[project.workingDirectory]?.isEmpty ?? true)
|
||||
ThreadButton(
|
||||
label: "No threads yet",
|
||||
muted: true,
|
||||
)
|
||||
else
|
||||
for (final session in sorted[project.workingDirectory]!)
|
||||
ThreadButton(
|
||||
label: session.name,
|
||||
lastMessage: session.updated,
|
||||
selected: sessionProvider.currentSessionId == session.id,
|
||||
onPressed: () => coordinator.openSession(session),
|
||||
onDelete: () => coordinator.deleteSession(session),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Gap(2),
|
||||
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../utils/format_relative_time.dart';
|
||||
|
||||
class ThreadButton extends StatelessWidget {
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final VoidCallback? onDelete;
|
||||
final DateTime? lastMessage;
|
||||
final bool selected;
|
||||
final bool muted;
|
||||
|
||||
ThreadButton({
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.onDelete,
|
||||
this.lastMessage,
|
||||
this.selected = false,
|
||||
this.muted = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
ButtonStyle style = selected ? ButtonStyle.secondary() : ButtonStyle.ghost();
|
||||
|
||||
ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final button = SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: style.copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 12,
|
||||
bottom: 8,
|
||||
right: 12
|
||||
);
|
||||
}
|
||||
),
|
||||
disableFocusOutline: true,
|
||||
onPressed: onPressed ?? () {},
|
||||
enabled: onPressed != null,
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: icon == null ? Colors.transparent : (muted ? colorScheme.mutedForeground : null),
|
||||
).iconSmall,
|
||||
|
||||
trailingGap: 32,
|
||||
trailing: lastMessage != null ?
|
||||
Text(
|
||||
formatRelativeTime(lastMessage!)
|
||||
).muted.small.light : null,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: (muted ? colorScheme.mutedForeground : null)
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small.light,
|
||||
),
|
||||
);
|
||||
|
||||
if (onDelete == null) return button;
|
||||
|
||||
return ContextMenu(
|
||||
items: [
|
||||
MenuButton(
|
||||
onPressed: (_) => onDelete!(),
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
child: button,
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user