mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
chore: update project management and API integration
- Added new scripts for server development and full application startup in package.json. - Enhanced project management by checking for existing projects to avoid duplicates. - Improved API integration with better error handling and connection checks in the Electron API. - Updated UI components to reflect changes in project and session management. - Refactored authentication status display to include more detailed information on methods used.
This commit is contained in:
562
apps/server/src/services/agent-service.ts
Normal file
562
apps/server/src/services/agent-service.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Agent Service - Runs Claude agents via the Claude Agent SDK
|
||||
* Manages conversation sessions and streams responses via WebSocket
|
||||
*/
|
||||
|
||||
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
images?: Array<{
|
||||
data: string;
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
}>;
|
||||
timestamp: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
messages: Message[];
|
||||
isRunning: boolean;
|
||||
abortController: AbortController | null;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
interface SessionMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
projectPath?: string;
|
||||
workingDirectory: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archived?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class AgentService {
|
||||
private sessions = new Map<string, Session>();
|
||||
private stateDir: string;
|
||||
private metadataFile: string;
|
||||
private events: EventEmitter;
|
||||
|
||||
constructor(dataDir: string, events: EventEmitter) {
|
||||
this.stateDir = path.join(dataDir, "agent-sessions");
|
||||
this.metadataFile = path.join(dataDir, "sessions-metadata.json");
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await fs.mkdir(this.stateDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or resume a conversation
|
||||
*/
|
||||
async startConversation({
|
||||
sessionId,
|
||||
workingDirectory,
|
||||
}: {
|
||||
sessionId: string;
|
||||
workingDirectory?: string;
|
||||
}) {
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
const messages = await this.loadSession(sessionId);
|
||||
this.sessions.set(sessionId, {
|
||||
messages,
|
||||
isRunning: false,
|
||||
abortController: null,
|
||||
workingDirectory: workingDirectory || process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
const session = this.sessions.get(sessionId)!;
|
||||
return {
|
||||
success: true,
|
||||
messages: session.messages,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the agent and stream responses
|
||||
*/
|
||||
async sendMessage({
|
||||
sessionId,
|
||||
message,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.isRunning) {
|
||||
throw new Error("Agent is already processing a message");
|
||||
}
|
||||
|
||||
// Read images and convert to base64
|
||||
const images: Message["images"] = [];
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
const mediaType = mimeTypeMap[ext] || "image/png";
|
||||
|
||||
images.push({
|
||||
data: base64Data,
|
||||
mimeType: mediaType,
|
||||
filename: path.basename(imagePath),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role: "user",
|
||||
content: message,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
session.messages.push(userMessage);
|
||||
session.isRunning = true;
|
||||
session.abortController = new AbortController();
|
||||
|
||||
// Emit user message event
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "message",
|
||||
message: userMessage,
|
||||
});
|
||||
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
try {
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
systemPrompt: this.getSystemPrompt(),
|
||||
maxTurns: 20,
|
||||
cwd: workingDirectory || session.workingDirectory,
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
],
|
||||
permissionMode: "acceptEdits",
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
abortController: session.abortController!,
|
||||
};
|
||||
|
||||
// Build prompt content
|
||||
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
|
||||
message;
|
||||
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
|
||||
|
||||
if (message && message.trim()) {
|
||||
contentBlocks.push({ type: "text", text: message });
|
||||
}
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
const mediaType = mimeTypeMap[ext] || "image/png";
|
||||
|
||||
contentBlocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: mediaType,
|
||||
data: base64Data,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") {
|
||||
promptContent = contentBlocks;
|
||||
}
|
||||
}
|
||||
|
||||
// Build payload
|
||||
const promptPayload = Array.isArray(promptContent)
|
||||
? (async function* () {
|
||||
yield {
|
||||
type: "user" as const,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: promptContent,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})()
|
||||
: promptContent;
|
||||
|
||||
const stream = query({ prompt: promptPayload, options });
|
||||
|
||||
let currentAssistantMessage: Message | null = null;
|
||||
let responseText = "";
|
||||
const toolUses: Array<{ name: string; input: unknown }> = [];
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant") {
|
||||
if (msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText += block.text;
|
||||
|
||||
if (!currentAssistantMessage) {
|
||||
currentAssistantMessage = {
|
||||
id: this.generateId(),
|
||||
role: "assistant",
|
||||
content: responseText,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
session.messages.push(currentAssistantMessage);
|
||||
} else {
|
||||
currentAssistantMessage.content = responseText;
|
||||
}
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "stream",
|
||||
messageId: currentAssistantMessage.id,
|
||||
content: responseText,
|
||||
isComplete: false,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
const toolUse = {
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
};
|
||||
toolUses.push(toolUse);
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "tool_use",
|
||||
tool: toolUse,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result") {
|
||||
if (msg.subtype === "success" && msg.result) {
|
||||
if (currentAssistantMessage) {
|
||||
currentAssistantMessage.content = msg.result;
|
||||
responseText = msg.result;
|
||||
}
|
||||
}
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "complete",
|
||||
messageId: currentAssistantMessage?.id,
|
||||
content: responseText,
|
||||
toolUses,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: currentAssistantMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
return { success: false, aborted: true };
|
||||
}
|
||||
|
||||
console.error("[AgentService] Error:", error);
|
||||
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role: "assistant",
|
||||
content: `Error: ${(error as Error).message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
isError: true,
|
||||
};
|
||||
|
||||
session.messages.push(errorMessage);
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "error",
|
||||
error: (error as Error).message,
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation history
|
||||
*/
|
||||
getHistory(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { success: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: session.messages,
|
||||
isRunning: session.isRunning,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current agent execution
|
||||
*/
|
||||
async stopExecution(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { success: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
if (session.abortController) {
|
||||
session.abortController.abort();
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conversation history
|
||||
*/
|
||||
async clearSession(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.messages = [];
|
||||
session.isRunning = false;
|
||||
await this.saveSession(sessionId, []);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Session management
|
||||
|
||||
async loadSession(sessionId: string): Promise<Message[]> {
|
||||
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(sessionFile, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async saveSession(sessionId: string, messages: Message[]): Promise<void> {
|
||||
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(sessionFile, JSON.stringify(messages, null, 2), "utf-8");
|
||||
await this.updateSessionTimestamp(sessionId);
|
||||
} catch (error) {
|
||||
console.error("[AgentService] Failed to save session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
|
||||
try {
|
||||
const data = await fs.readFile(this.metadataFile, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
|
||||
await fs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async updateSessionTimestamp(sessionId: string): Promise<void> {
|
||||
const metadata = await this.loadMetadata();
|
||||
if (metadata[sessionId]) {
|
||||
metadata[sessionId].updatedAt = new Date().toISOString();
|
||||
await this.saveMetadata(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
async listSessions(includeArchived = false): Promise<SessionMetadata[]> {
|
||||
const metadata = await this.loadMetadata();
|
||||
let sessions = Object.values(metadata);
|
||||
|
||||
if (!includeArchived) {
|
||||
sessions = sessions.filter((s) => !s.archived);
|
||||
}
|
||||
|
||||
return sessions.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
name: string,
|
||||
projectPath?: string,
|
||||
workingDirectory?: string
|
||||
): Promise<SessionMetadata> {
|
||||
const sessionId = this.generateId();
|
||||
const metadata = await this.loadMetadata();
|
||||
|
||||
const session: SessionMetadata = {
|
||||
id: sessionId,
|
||||
name,
|
||||
projectPath,
|
||||
workingDirectory: workingDirectory || projectPath || process.cwd(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
metadata[sessionId] = session;
|
||||
await this.saveMetadata(metadata);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async updateSession(
|
||||
sessionId: string,
|
||||
updates: Partial<SessionMetadata>
|
||||
): Promise<SessionMetadata | null> {
|
||||
const metadata = await this.loadMetadata();
|
||||
if (!metadata[sessionId]) return null;
|
||||
|
||||
metadata[sessionId] = {
|
||||
...metadata[sessionId],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.saveMetadata(metadata);
|
||||
return metadata[sessionId];
|
||||
}
|
||||
|
||||
async archiveSession(sessionId: string): Promise<boolean> {
|
||||
const result = await this.updateSession(sessionId, { archived: true });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async unarchiveSession(sessionId: string): Promise<boolean> {
|
||||
const result = await this.updateSession(sessionId, { archived: false });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
const metadata = await this.loadMetadata();
|
||||
if (!metadata[sessionId]) return false;
|
||||
|
||||
delete metadata[sessionId];
|
||||
await this.saveMetadata(metadata);
|
||||
|
||||
// Delete session file
|
||||
try {
|
||||
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
||||
await fs.unlink(sessionFile);
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
|
||||
// Clear from memory
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
|
||||
this.events.emit("agent:stream", { sessionId, ...data });
|
||||
}
|
||||
|
||||
private getSystemPrompt(): string {
|
||||
return `You are an AI assistant helping users build software. You are part of the Automaker application,
|
||||
which is designed to help developers plan, design, and implement software projects autonomously.
|
||||
|
||||
**Feature Storage:**
|
||||
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
|
||||
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
|
||||
|
||||
Your role is to:
|
||||
- Help users define their project requirements and specifications
|
||||
- Ask clarifying questions to better understand their needs
|
||||
- Suggest technical approaches and architectures
|
||||
- Guide them through the development process
|
||||
- Be conversational and helpful
|
||||
- Write, edit, and modify code files as requested
|
||||
- Execute commands and tests
|
||||
- Search and analyze the codebase
|
||||
|
||||
When discussing projects, help users think through:
|
||||
- Core functionality and features
|
||||
- Technical stack choices
|
||||
- Data models and architecture
|
||||
- User experience considerations
|
||||
- Testing strategies
|
||||
|
||||
You have full access to the codebase and can:
|
||||
- Read files to understand existing code
|
||||
- Write new files
|
||||
- Edit existing files
|
||||
- Run bash commands
|
||||
- Search for code patterns
|
||||
- Execute tests and builds`;
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user