From 71a7d0cc2c4aba1f769c24dbd371ae4760218aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=97=B0=E6=98=8E?= Date: Sun, 1 Mar 2026 20:27:57 +0800 Subject: [PATCH 1/9] feat: add mini-muse - AI-powered web application generator Mini-Muse is a fullstack AI agent example that generates complete React + Tailwind web applications from natural language descriptions. Tech stack: - Backend: Egg.js 3 + TypeScript - Frontend: React 18 + Vite + Tailwind CSS - AI: Anthropic Claude API with tool use Features: - AI agent loop with structured tool calls - Real-time progress streaming via SSE - Live preview and project download --- mini-muse/.env.example | 7 + mini-muse/CLAUDE.md | 20 + mini-muse/README.md | 62 ++++ mini-muse/app/controller/generate.ts | 343 ++++++++++++++++++ mini-muse/app/controller/home.ts | 18 + mini-muse/app/lib/prompts.ts | 115 ++++++ .../lib/tools/analyzer/analyzeRequirements.ts | 160 ++++++++ .../lib/tools/analyzer/planArchitecture.ts | 176 +++++++++ .../lib/tools/generator/createComponent.ts | 115 ++++++ .../app/lib/tools/generator/createConfig.ts | 205 +++++++++++ .../app/lib/tools/generator/createFile.ts | 78 ++++ .../app/lib/tools/generator/createHook.ts | 90 +++++ .../app/lib/tools/generator/createPage.ts | 92 +++++ .../app/lib/tools/generator/createStyle.ts | 63 ++++ .../app/lib/tools/generator/deleteFile.ts | 55 +++ mini-muse/app/lib/tools/reader/readFile.ts | 56 +++ mini-muse/app/lib/tools/registry.ts | 147 ++++++++ .../lib/tools/validator/validateProject.ts | 235 ++++++++++++ mini-muse/app/lib/utils.ts | 88 +++++ mini-muse/app/middleware/errorHandler.ts | 24 ++ mini-muse/app/router.ts | 20 + mini-muse/app/service/aiClient.ts | 98 +++++ mini-muse/app/service/orchestrator.ts | 262 +++++++++++++ mini-muse/app/service/preview.ts | 146 ++++++++ mini-muse/app/service/taskManager.ts | 100 +++++ mini-muse/app/service/tools.ts | 13 + mini-muse/config/config.default.ts | 33 ++ mini-muse/config/config.local.ts | 6 + mini-muse/config/plugin.ts | 7 + mini-muse/docs/design.md | 316 ++++++++++++++++ mini-muse/frontend/index.html | 12 + mini-muse/frontend/package.json | 26 ++ mini-muse/frontend/postcss.config.js | 6 + mini-muse/frontend/src/App.tsx | 24 ++ .../frontend/src/components/ChatPanel.tsx | 188 ++++++++++ .../frontend/src/components/CodePreview.tsx | 41 +++ .../frontend/src/components/FileTree.tsx | 93 +++++ .../frontend/src/components/ProgressLog.tsx | 57 +++ mini-muse/frontend/src/index.css | 7 + mini-muse/frontend/src/main.tsx | 13 + mini-muse/frontend/src/pages/HomePage.tsx | 82 +++++ mini-muse/frontend/src/pages/ProgressPage.tsx | 80 ++++ mini-muse/frontend/src/pages/ResultPage.tsx | 242 ++++++++++++ mini-muse/frontend/src/services/api.ts | 101 ++++++ mini-muse/frontend/src/types/index.ts | 43 +++ mini-muse/frontend/tailwind.config.js | 8 + mini-muse/frontend/tsconfig.json | 20 + mini-muse/frontend/vite.config.ts | 15 + mini-muse/package.json | 41 +++ mini-muse/tsconfig.json | 22 ++ 50 files changed, 4271 insertions(+) create mode 100644 mini-muse/.env.example create mode 100644 mini-muse/CLAUDE.md create mode 100644 mini-muse/README.md create mode 100644 mini-muse/app/controller/generate.ts create mode 100644 mini-muse/app/controller/home.ts create mode 100644 mini-muse/app/lib/prompts.ts create mode 100644 mini-muse/app/lib/tools/analyzer/analyzeRequirements.ts create mode 100644 mini-muse/app/lib/tools/analyzer/planArchitecture.ts create mode 100644 mini-muse/app/lib/tools/generator/createComponent.ts create mode 100644 mini-muse/app/lib/tools/generator/createConfig.ts create mode 100644 mini-muse/app/lib/tools/generator/createFile.ts create mode 100644 mini-muse/app/lib/tools/generator/createHook.ts create mode 100644 mini-muse/app/lib/tools/generator/createPage.ts create mode 100644 mini-muse/app/lib/tools/generator/createStyle.ts create mode 100644 mini-muse/app/lib/tools/generator/deleteFile.ts create mode 100644 mini-muse/app/lib/tools/reader/readFile.ts create mode 100644 mini-muse/app/lib/tools/registry.ts create mode 100644 mini-muse/app/lib/tools/validator/validateProject.ts create mode 100644 mini-muse/app/lib/utils.ts create mode 100644 mini-muse/app/middleware/errorHandler.ts create mode 100644 mini-muse/app/router.ts create mode 100644 mini-muse/app/service/aiClient.ts create mode 100644 mini-muse/app/service/orchestrator.ts create mode 100644 mini-muse/app/service/preview.ts create mode 100644 mini-muse/app/service/taskManager.ts create mode 100644 mini-muse/app/service/tools.ts create mode 100644 mini-muse/config/config.default.ts create mode 100644 mini-muse/config/config.local.ts create mode 100644 mini-muse/config/plugin.ts create mode 100644 mini-muse/docs/design.md create mode 100644 mini-muse/frontend/index.html create mode 100644 mini-muse/frontend/package.json create mode 100644 mini-muse/frontend/postcss.config.js create mode 100644 mini-muse/frontend/src/App.tsx create mode 100644 mini-muse/frontend/src/components/ChatPanel.tsx create mode 100644 mini-muse/frontend/src/components/CodePreview.tsx create mode 100644 mini-muse/frontend/src/components/FileTree.tsx create mode 100644 mini-muse/frontend/src/components/ProgressLog.tsx create mode 100644 mini-muse/frontend/src/index.css create mode 100644 mini-muse/frontend/src/main.tsx create mode 100644 mini-muse/frontend/src/pages/HomePage.tsx create mode 100644 mini-muse/frontend/src/pages/ProgressPage.tsx create mode 100644 mini-muse/frontend/src/pages/ResultPage.tsx create mode 100644 mini-muse/frontend/src/services/api.ts create mode 100644 mini-muse/frontend/src/types/index.ts create mode 100644 mini-muse/frontend/tailwind.config.js create mode 100644 mini-muse/frontend/tsconfig.json create mode 100644 mini-muse/frontend/vite.config.ts create mode 100644 mini-muse/package.json create mode 100644 mini-muse/tsconfig.json diff --git a/mini-muse/.env.example b/mini-muse/.env.example new file mode 100644 index 0000000..13dcee2 --- /dev/null +++ b/mini-muse/.env.example @@ -0,0 +1,7 @@ +# Anthropic API Configuration +# Option 1: Use API Key (direct Anthropic API) +ANTHROPIC_API_KEY=sk-ant-your-api-key-here + +# Option 2: Use custom proxy endpoint +# ANTHROPIC_BASE_URL=https://your-proxy-endpoint.com/anthropic +# ANTHROPIC_AUTH_TOKEN=your-auth-token-here diff --git a/mini-muse/CLAUDE.md b/mini-muse/CLAUDE.md new file mode 100644 index 0000000..70c602d --- /dev/null +++ b/mini-muse/CLAUDE.md @@ -0,0 +1,20 @@ +# Mini-Muse 项目规范 + +## 文档同步要求 + +每次通过 Claude Code 修改代码实现后,**必须**检查并同步更新 `docs/design.md` 设计文档: + +1. **新增功能**:在设计文档中补充对应的模块描述、数据流、API 接口 +2. **架构变更**:更新架构图(使用 Mermaid 语法)和模块关系 +3. **接口变更**:更新 API 路由表、请求/响应格式 +4. **删除功能**:从文档中移除对应描述,避免文档与代码不一致 + +设计文档 `docs/design.md` 应包含: +- 项目概述与核心功能 +- 系统架构图(Mermaid) +- 技术栈说明 +- 后端模块划分与职责 +- 前端页面与组件结构 +- API 接口定义 +- 数据流与核心流程(SSE、AI Agent Loop 等) +- 关键设计决策及原因 diff --git a/mini-muse/README.md b/mini-muse/README.md new file mode 100644 index 0000000..6ae14f1 --- /dev/null +++ b/mini-muse/README.md @@ -0,0 +1,62 @@ +# Mini-Muse + +AI-powered web application generator. Describe what you want, and Mini-Muse uses Claude as an AI agent to generate a complete React + Tailwind project. + +## Features + +- Natural language to web app generation +- Real-time progress streaming via SSE +- AI agent loop with tool use (analyze, plan, generate, validate) +- Live preview and file download + +## Tech Stack + +- **Backend**: Egg.js 3 + TypeScript +- **Frontend**: React 18 + Vite + Tailwind CSS +- **AI**: Anthropic Claude API (tool use) + +## Quick Start + +### Prerequisites + +- Node.js >= 18 +- npm or other package manager + +### 1. Install dependencies + +```bash +# Backend +npm install + +# Frontend +cd frontend && npm install && cd .. +``` + +### 2. Configure environment + +```bash +cp .env.example .env +# Edit .env with your Anthropic API key +``` + +### 3. Build frontend + +```bash +cd frontend && npm run build && cd .. +``` + +### 4. Start + +```bash +# Development +npm run dev + +# Production +npm run build && npm start +``` + +The app will be available at http://127.0.0.1:7001 + +## Documentation + +See [docs/design.md](docs/design.md) for detailed architecture and design documentation. diff --git a/mini-muse/app/controller/generate.ts b/mini-muse/app/controller/generate.ts new file mode 100644 index 0000000..ce775d7 --- /dev/null +++ b/mini-muse/app/controller/generate.ts @@ -0,0 +1,343 @@ +import { Controller } from 'egg'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import archiver from 'archiver'; +import { listFiles } from '../lib/utils'; + +export default class GenerateController extends Controller { + /** + * POST /api/generate + * Create a new generation task + */ + async create() { + const { ctx } = this; + const { description, appName } = ctx.request.body as { description?: string; appName?: string }; + + if (!description) { + ctx.status = 400; + ctx.body = { success: false, error: 'description is required' }; + return; + } + + const name = appName || 'my-app'; + const task = ctx.service.taskManager.createTask(description, name); + + // Run orchestrator asynchronously (don't await) + ctx.service.orchestrator.run(task.id, description).catch(err => { + ctx.logger.error('Orchestrator error for task %s: %s', task.id, err.message); + }); + + ctx.body = { + success: true, + data: { + taskId: task.id, + status: task.status, + }, + }; + } + + /** + * GET /api/generate/:taskId/status + * SSE stream for task progress + */ + async status() { + const { ctx } = this; + const { taskId } = ctx.params; + + const task = ctx.service.taskManager.getTask(taskId); + if (!task) { + ctx.status = 404; + ctx.body = { success: false, error: 'Task not found' }; + return; + } + + // Set SSE headers + ctx.res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + ctx.respond = false; + + // Send existing progress events first + for (const event of task.progress) { + ctx.res.write(`data: ${JSON.stringify(event)}\n\n`); + } + + // Subscribe to new events (keep connection open for modifications) + const unsubscribe = ctx.service.taskManager.subscribe(taskId, (event) => { + try { + ctx.res.write(`data: ${JSON.stringify(event)}\n\n`); + } catch { + // Client disconnected + unsubscribe(); + } + }); + + // Cleanup on client disconnect + ctx.req.on('close', () => { + unsubscribe(); + }); + } + + /** + * POST /api/generate/:taskId/modify + * Send a modification instruction for a completed task + */ + async modify() { + const { ctx } = this; + const { taskId } = ctx.params; + const { instruction } = ctx.request.body as { instruction?: string }; + + if (!instruction) { + ctx.status = 400; + ctx.body = { success: false, error: 'instruction is required' }; + return; + } + + const task = ctx.service.taskManager.getTask(taskId); + if (!task) { + ctx.status = 404; + ctx.body = { success: false, error: 'Task not found' }; + return; + } + + if (task.status !== 'completed') { + ctx.status = 400; + ctx.body = { success: false, error: 'Task must be completed before modification' }; + return; + } + + // Reset status to running + task.status = 'running'; + + // Add user instruction to chat history + task.chatHistory.push({ + role: 'user', + content: instruction, + timestamp: Date.now(), + }); + + // Run modification asynchronously + ctx.service.orchestrator.modify(taskId, instruction).catch(err => { + ctx.logger.error('Orchestrator modify error for task %s: %s', taskId, err.message); + }); + + ctx.body = { + success: true, + data: { + taskId: task.id, + status: task.status, + }, + }; + } + + /** + * GET /api/generate/:taskId/chat + * Get chat history for a task + */ + async chatHistory() { + const { ctx } = this; + const { taskId } = ctx.params; + + const task = ctx.service.taskManager.getTask(taskId); + if (!task) { + ctx.status = 404; + ctx.body = { success: false, error: 'Task not found' }; + return; + } + + ctx.body = { + success: true, + data: { + chatHistory: task.chatHistory, + }, + }; + } + + /** + * GET /api/generate/:taskId/files + * Return file tree for a completed task + */ + async files() { + const { ctx } = this; + const { taskId } = ctx.params; + + const task = ctx.service.taskManager.getTask(taskId); + if (!task) { + ctx.status = 404; + ctx.body = { success: false, error: 'Task not found' }; + return; + } + + try { + const allFiles = await listFiles(task.outputDir); + const fileTree = allFiles.map(f => path.relative(task.outputDir, f)); + + ctx.body = { + success: true, + data: { + taskId: task.id, + files: fileTree, + }, + }; + } catch { + ctx.body = { + success: true, + data: { + taskId: task.id, + files: [], + }, + }; + } + } + + /** + * GET /api/generate/:taskId/file?path=xxx + * Return content of a specific file + */ + async fileContent() { + const { ctx } = this; + const { taskId } = ctx.params; + const filePath = ctx.query.path as string; + + if (!filePath) { + ctx.status = 400; + ctx.body = { success: false, error: 'path query parameter is required' }; + return; + } + + const task = ctx.service.taskManager.getTask(taskId); + if (!task) { + ctx.status = 404; + ctx.body = { success: false, error: 'Task not found' }; + return; + } + + // Prevent path traversal + const fullPath = path.resolve(task.outputDir, filePath); + if (!fullPath.startsWith(path.resolve(task.outputDir))) { + ctx.status = 403; + ctx.body = { success: false, error: 'Access denied' }; + return; + } + + try { + const content = await fs.readFile(fullPath, 'utf-8'); + ctx.body = { + success: true, + data: { + path: filePath, + content, + }, + }; + } catch { + ctx.status = 404; + ctx.body = { success: false, error: 'File not found' }; + } + } + + /** + * GET /api/generate/:taskId/download + * Download the generated project as a zip file + */ + async download() { + const { ctx } = this; + const { taskId } = ctx.params; + + const task = ctx.service.taskManager.getTask(taskId); + if (!task) { + ctx.status = 404; + ctx.body = { success: false, error: 'Task not found' }; + return; + } + + if (task.status !== 'completed') { + ctx.status = 400; + ctx.body = { success: false, error: 'Task is not completed yet' }; + return; + } + + ctx.res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${task.appName}.zip"`, + }); + ctx.respond = false; + + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.pipe(ctx.res); + archive.directory(task.outputDir, task.appName); + await archive.finalize(); + } + + /** + * POST /api/generate/:taskId/preview + * Start a preview dev server for the generated project + */ + async startPreview() { + const { ctx } = this; + const { taskId } = ctx.params; + + try { + const info = await ctx.service.preview.start(taskId); + ctx.body = { + success: true, + data: { + status: info.status, + port: info.port, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.status = 400; + ctx.body = { success: false, error: msg }; + } + } + + /** + * GET /api/generate/:taskId/preview + * Get preview server status + */ + async previewStatus() { + const { ctx } = this; + const { taskId } = ctx.params; + + const info = ctx.service.preview.getStatus(taskId); + if (!info) { + ctx.body = { success: true, data: { status: 'none' } }; + return; + } + + ctx.body = { + success: true, + data: { + status: info.status, + port: info.port, + error: info.error, + }, + }; + } + + /** + * GET /api/tasks + * List all tasks + */ + async list() { + const { ctx } = this; + const allTasks = ctx.service.taskManager.listTasks(); + + ctx.body = { + success: true, + data: allTasks.map(t => ({ + id: t.id, + status: t.status, + description: t.description, + appName: t.appName, + filesCreated: t.filesCreated.length, + createdAt: t.createdAt, + error: t.error, + })), + }; + } +} diff --git a/mini-muse/app/controller/home.ts b/mini-muse/app/controller/home.ts new file mode 100644 index 0000000..74f4e0a --- /dev/null +++ b/mini-muse/app/controller/home.ts @@ -0,0 +1,18 @@ +import { Controller } from 'egg'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +export default class HomeController extends Controller { + async index() { + const { ctx } = this; + const indexPath = path.join(this.app.baseDir, 'app/public/index.html'); + + try { + const html = await fs.readFile(indexPath, 'utf-8'); + ctx.type = 'text/html'; + ctx.body = html; + } catch { + ctx.body = 'Mini-Muse is running. Please build the frontend first: cd frontend && npm run build'; + } + } +} diff --git a/mini-muse/app/lib/prompts.ts b/mini-muse/app/lib/prompts.ts new file mode 100644 index 0000000..607d5cf --- /dev/null +++ b/mini-muse/app/lib/prompts.ts @@ -0,0 +1,115 @@ +export const SYSTEM_PROMPT = `You are Mini-Muse, an expert React + TypeScript web application generator. Your task is to create complete, production-ready web applications based on user requirements. + +## Your Capabilities + +You have access to tools for: +1. **Analysis**: Understand and extract requirements from user descriptions +2. **Planning**: Design application architecture and file structure +3. **Generation**: Create all necessary code files +4. **Validation**: Verify the generated project is complete and correct + +## Workflow + +Follow this structured approach for every project: + +### Phase 1: Requirements Analysis +- Use \`analyze_requirements\` to parse the user's description +- Extract: pages, components, data models, features, and interactions +- Identify any ambiguities and make reasonable assumptions + +### Phase 2: Architecture Planning +- Use \`plan_architecture\` to design the project structure +- Define component hierarchy and relationships +- Plan the order of file creation (dependencies first) + +### Phase 3: Code Generation +Execute in this order: +1. \`create_config\` - package.json, tsconfig.json, vite.config.ts, index.html +2. \`create_file\` - Type definitions, utility functions, constants +3. \`create_hook\` - Custom React hooks for shared logic +4. \`create_component\` - Reusable UI components (bottom-up) +5. \`create_page\` - Page components with routing +6. \`create_style\` - Global styles and themes +7. \`create_file\` - App.tsx and main.tsx entry files + +### Phase 4: Validation +- Use \`validate_project\` to check completeness +- Ensure all imports resolve correctly +- Verify no circular dependencies + +## Code Quality Standards + +Generate code that: +- Uses TypeScript with strict typing (no \`any\`) +- Follows React best practices (functional components, hooks) +- Has clean, readable formatting +- Includes meaningful comments for complex logic +- Uses semantic HTML and accessible patterns +- Implements responsive design with CSS modules or styled-components + +## Technology Stack + +Always generate projects using: +- **Build Tool**: Vite +- **Framework**: React 18+ +- **Language**: TypeScript (strict mode) +- **Styling**: CSS Modules (*.module.css) +- **Routing**: React Router v6 (if multiple pages) +- **State**: React useState/useReducer (keep it simple) + +## Important Guidelines + +1. **Be Complete**: Generate ALL files needed for a working application +2. **Be Consistent**: Use consistent naming conventions throughout +3. **Be Practical**: Make reasonable assumptions for undefined requirements +4. **Be Modern**: Use modern React patterns (hooks, functional components) +5. **Be Organized**: Follow a clear project structure + +When uncertain about specific requirements, choose sensible defaults that work well for most use cases.`; + +export const MODIFY_SYSTEM_PROMPT = `${SYSTEM_PROMPT} + +## Modification Mode + +You are now in modification mode. The user has already generated a project and wants to make changes. + +### Rules for Modification: +1. **Read before modify**: Use \`read_file\` to read any file you need to understand before modifying it +2. **Minimal changes**: Only modify files that need to change. Do NOT recreate files that don't need changes +3. **Preserve structure**: Keep the existing project structure unless the user explicitly asks to restructure +4. **Use existing tools**: Use \`create_file\`, \`create_component\`, etc. to overwrite files that need changes +5. **Skip analysis/planning**: Do NOT call \`analyze_requirements\` or \`plan_architecture\` for modifications — go directly to making changes +6. **Summarize changes**: After making all changes, provide a brief summary of what was modified and why +`; + +export function createModifyPrompt(instruction: string, existingFiles: string[]): string { + return `The project has been generated with the following file structure: + +\`\`\` +${existingFiles.join('\n')} +\`\`\` + +The user wants to make the following modification: + +--- +${instruction} +--- + +Please read the relevant files first to understand the current implementation, then make the necessary changes. Only modify files that need to change.`; +} + +export function createUserPrompt(description: string): string { + return `Please create a complete React + TypeScript web application based on the following description: + +--- +${description} +--- + +Follow the standard workflow: +1. First analyze the requirements +2. Plan the architecture +3. Generate all necessary files +4. Validate the project + +Start by analyzing the requirements.`; +} diff --git a/mini-muse/app/lib/tools/analyzer/analyzeRequirements.ts b/mini-muse/app/lib/tools/analyzer/analyzeRequirements.ts new file mode 100644 index 0000000..4ab00f5 --- /dev/null +++ b/mini-muse/app/lib/tools/analyzer/analyzeRequirements.ts @@ -0,0 +1,160 @@ +import { ToolResult } from '../registry'; + +export const analyzeRequirementsSchema = { + type: 'object' as const, + properties: { + description: { + type: 'string', + description: 'The original user description of the application', + }, + appName: { + type: 'string', + description: 'A kebab-case name for the application (e.g., "todo-app")', + }, + appTitle: { + type: 'string', + description: 'Human-readable title for the application (e.g., "Todo App")', + }, + pages: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'PascalCase page name' }, + path: { type: 'string', description: 'URL path for the page' }, + description: { type: 'string', description: 'What this page does' }, + }, + required: ['name', 'path', 'description'], + }, + description: 'List of pages/routes in the application', + }, + components: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'PascalCase component name' }, + description: { type: 'string', description: 'What this component does' }, + props: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + type: { type: 'string' }, + required: { type: 'boolean' }, + }, + }, + }, + }, + required: ['name', 'description'], + }, + description: 'List of reusable components needed', + }, + dataModels: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'PascalCase type name' }, + description: { type: 'string', description: 'What this type represents' }, + fields: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + type: { type: 'string' }, + optional: { type: 'boolean' }, + }, + }, + }, + }, + required: ['name', 'fields'], + }, + description: 'TypeScript interfaces/types for the data model', + }, + features: { + type: 'array', + items: { type: 'string' }, + description: 'List of features the application should have', + }, + hooks: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Hook name starting with "use"' }, + description: { type: 'string', description: 'What this hook does' }, + }, + required: ['name', 'description'], + }, + description: 'Custom hooks needed for the application', + }, + }, + required: ['description', 'appName', 'appTitle', 'pages', 'components', 'dataModels', 'features'], +}; + +export interface AnalyzeRequirementsInput { + description: string; + appName: string; + appTitle: string; + pages: Array<{ + name: string; + path: string; + description: string; + }>; + components: Array<{ + name: string; + description: string; + props?: Array<{ + name: string; + type: string; + required?: boolean; + }>; + }>; + dataModels: Array<{ + name: string; + description?: string; + fields: Array<{ + name: string; + type: string; + optional?: boolean; + }>; + }>; + features: string[]; + hooks?: Array<{ + name: string; + description: string; + }>; +} + +export async function analyzeRequirements( + input: Record, + _outputDir: string +): Promise { + const data = input as unknown as AnalyzeRequirementsInput; + + // Validate required fields + if (!data.appName || !data.pages || !data.components || !data.dataModels) { + return { + success: false, + error: 'Missing required fields in requirements analysis', + }; + } + + // Return the analyzed requirements for the next phase + return { + success: true, + message: `Analyzed requirements for "${data.appTitle}": ${data.pages.length} pages, ${data.components.length} components, ${data.dataModels.length} data models`, + data: { + appName: data.appName, + appTitle: data.appTitle, + pages: data.pages, + components: data.components, + dataModels: data.dataModels, + features: data.features, + hooks: data.hooks || [], + }, + }; +} diff --git a/mini-muse/app/lib/tools/analyzer/planArchitecture.ts b/mini-muse/app/lib/tools/analyzer/planArchitecture.ts new file mode 100644 index 0000000..29f326d --- /dev/null +++ b/mini-muse/app/lib/tools/analyzer/planArchitecture.ts @@ -0,0 +1,176 @@ +import { ToolResult } from '../registry'; + +export const planArchitectureSchema = { + type: 'object' as const, + properties: { + appName: { + type: 'string', + description: 'The application name from requirements analysis', + }, + structure: { + type: 'object', + properties: { + src: { + type: 'array', + items: { type: 'string' }, + description: 'Files directly in src/ directory', + }, + components: { + type: 'array', + items: { type: 'string' }, + description: 'Component directories to create', + }, + pages: { + type: 'array', + items: { type: 'string' }, + description: 'Page directories to create', + }, + hooks: { + type: 'array', + items: { type: 'string' }, + description: 'Hook files to create', + }, + types: { + type: 'array', + items: { type: 'string' }, + description: 'Type definition files to create', + }, + utils: { + type: 'array', + items: { type: 'string' }, + description: 'Utility files to create', + }, + styles: { + type: 'array', + items: { type: 'string' }, + description: 'Style files to create', + }, + }, + required: ['src', 'components', 'pages'], + }, + fileCreationOrder: { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path relative to project root' }, + type: { + type: 'string', + enum: ['config', 'type', 'util', 'hook', 'component', 'page', 'style', 'entry'], + description: 'Type of file', + }, + description: { type: 'string', description: 'What this file contains' }, + }, + required: ['path', 'type', 'description'], + }, + description: 'Ordered list of files to create (dependencies first)', + }, + componentHierarchy: { + type: 'array', + items: { + type: 'object', + properties: { + component: { type: 'string' }, + usedIn: { + type: 'array', + items: { type: 'string' }, + }, + uses: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['component'], + }, + description: 'Component dependency graph', + }, + routingConfig: { + type: 'object', + properties: { + hasRouter: { type: 'boolean' }, + routes: { + type: 'array', + items: { + type: 'object', + properties: { + path: { type: 'string' }, + component: { type: 'string' }, + isIndex: { type: 'boolean' }, + }, + }, + }, + }, + description: 'Routing configuration if multiple pages', + }, + }, + required: ['appName', 'structure', 'fileCreationOrder'], +}; + +export interface PlanArchitectureInput { + appName: string; + structure: { + src: string[]; + components: string[]; + pages: string[]; + hooks?: string[]; + types?: string[]; + utils?: string[]; + styles?: string[]; + }; + fileCreationOrder: Array<{ + path: string; + type: 'config' | 'type' | 'util' | 'hook' | 'component' | 'page' | 'style' | 'entry'; + description: string; + }>; + componentHierarchy?: Array<{ + component: string; + usedIn?: string[]; + uses?: string[]; + }>; + routingConfig?: { + hasRouter: boolean; + routes?: Array<{ + path: string; + component: string; + isIndex?: boolean; + }>; + }; +} + +export async function planArchitecture( + input: Record, + _outputDir: string +): Promise { + const data = input as unknown as PlanArchitectureInput; + + if (!data.appName || !data.structure || !data.fileCreationOrder) { + return { + success: false, + error: 'Missing required fields in architecture plan', + }; + } + + // Generate directory structure summary + const dirs = [ + 'src/', + data.structure.components.length > 0 ? 'src/components/' : null, + data.structure.pages.length > 0 ? 'src/pages/' : null, + data.structure.hooks?.length ? 'src/hooks/' : null, + data.structure.types?.length ? 'src/types/' : null, + data.structure.utils?.length ? 'src/utils/' : null, + data.structure.styles?.length ? 'src/styles/' : null, + ].filter(Boolean); + + return { + success: true, + message: `Architecture planned: ${data.fileCreationOrder.length} files to create in ${dirs.length} directories`, + data: { + appName: data.appName, + directories: dirs, + fileCount: data.fileCreationOrder.length, + fileCreationOrder: data.fileCreationOrder, + componentHierarchy: data.componentHierarchy || [], + routingConfig: data.routingConfig || { hasRouter: false }, + }, + }; +} diff --git a/mini-muse/app/lib/tools/generator/createComponent.ts b/mini-muse/app/lib/tools/generator/createComponent.ts new file mode 100644 index 0000000..765520e --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createComponent.ts @@ -0,0 +1,115 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode, toPascalCase } from '../../utils'; + +export const createComponentSchema = { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: 'Component name in PascalCase (e.g., "TodoItem", "Header")', + }, + description: { + type: 'string', + description: 'Brief description of what the component does', + }, + props: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Prop name' }, + type: { type: 'string', description: 'TypeScript type' }, + required: { type: 'boolean', description: 'Whether the prop is required' }, + defaultValue: { type: 'string', description: 'Default value if optional' }, + description: { type: 'string', description: 'Prop description' }, + }, + required: ['name', 'type'], + }, + description: 'Component props', + }, + componentCode: { + type: 'string', + description: 'The complete React component code (TSX)', + }, + styleCode: { + type: 'string', + description: 'CSS module styles for the component', + }, + directory: { + type: 'string', + description: 'Subdirectory within components/ (optional, e.g., "common", "layout")', + }, + }, + required: ['name', 'componentCode'], +}; + +export interface CreateComponentInput { + name: string; + description?: string; + props?: Array<{ + name: string; + type: string; + required?: boolean; + defaultValue?: string; + description?: string; + }>; + componentCode: string; + styleCode?: string; + directory?: string; +} + +export async function createComponent( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as CreateComponentInput; + + if (!data.name || !data.componentCode) { + return { + success: false, + error: 'name and componentCode are required', + }; + } + + const componentName = toPascalCase(data.name); + const filesCreated: string[] = []; + + // Determine component directory + const baseDir = data.directory + ? path.join(outputDir, 'src', 'components', data.directory, componentName) + : path.join(outputDir, 'src', 'components', componentName); + + // Format and write component file + const componentPath = path.join(baseDir, `${componentName}.tsx`); + const formattedComponent = await formatCode(data.componentCode, 'typescript'); + await writeFile(componentPath, formattedComponent); + filesCreated.push(componentPath); + + // Write style file if provided + if (data.styleCode) { + const stylePath = path.join(baseDir, `${componentName}.module.css`); + const formattedStyle = await formatCode(data.styleCode, 'css'); + await writeFile(stylePath, formattedStyle); + filesCreated.push(stylePath); + } + + // Create index.ts barrel export - check if component has default export + const indexPath = path.join(baseDir, 'index.ts'); + const hasDefault = /export\s+default\b/.test(data.componentCode); + const indexContent = hasDefault + ? `export { default } from './${componentName}';\nexport * from './${componentName}';\n` + : `export * from './${componentName}';\n`; + await writeFile(indexPath, indexContent); + filesCreated.push(indexPath); + + return { + success: true, + message: `Created component: ${componentName}`, + filesCreated, + data: { + componentName, + directory: baseDir, + }, + }; +} diff --git a/mini-muse/app/lib/tools/generator/createConfig.ts b/mini-muse/app/lib/tools/generator/createConfig.ts new file mode 100644 index 0000000..5b9807e --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createConfig.ts @@ -0,0 +1,205 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode } from '../../utils'; + +export const createConfigSchema = { + type: 'object' as const, + properties: { + configType: { + type: 'string', + enum: ['package.json', 'tsconfig.json', 'vite.config.ts', 'index.html'], + description: 'Type of configuration file to create', + }, + appName: { + type: 'string', + description: 'Application name for package.json', + }, + appTitle: { + type: 'string', + description: 'Application title for index.html', + }, + dependencies: { + type: 'object', + description: 'Additional dependencies to include in package.json', + }, + hasRouter: { + type: 'boolean', + description: 'Whether to include react-router-dom dependency', + }, + }, + required: ['configType'], +}; + +export interface CreateConfigInput { + configType: 'package.json' | 'tsconfig.json' | 'vite.config.ts' | 'index.html'; + appName?: string; + appTitle?: string; + dependencies?: Record; + hasRouter?: boolean; +} + +function generatePackageJson(input: CreateConfigInput): string { + const deps: Record = { + react: '^18.2.0', + 'react-dom': '^18.2.0', + ...(input.hasRouter ? { 'react-router-dom': '^6.22.0' } : {}), + ...(input.dependencies || {}), + }; + + const pkg = { + name: input.appName || 'my-app', + private: true, + version: '0.0.1', + type: 'module', + scripts: { + dev: 'vite', + build: 'tsc && vite build', + preview: 'vite preview', + }, + dependencies: deps, + devDependencies: { + '@types/react': '^18.2.0', + '@types/react-dom': '^18.2.0', + '@vitejs/plugin-react': '^4.2.0', + typescript: '^5.3.0', + vite: '^5.1.0', + }, + }; + + return JSON.stringify(pkg, null, 2); +} + +function generateTsConfig(): string { + const config = { + compilerOptions: { + target: 'ES2020', + useDefineForClassFields: true, + lib: ['ES2020', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + skipLibCheck: true, + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noFallthroughCasesInSwitch: true, + baseUrl: '.', + paths: { + '@/*': ['src/*'], + }, + }, + include: ['src'], + references: [{ path: './tsconfig.node.json' }], + }; + + return JSON.stringify(config, null, 2); +} + +function generateTsConfigNode(): string { + const config = { + compilerOptions: { + composite: true, + skipLibCheck: true, + module: 'ESNext', + moduleResolution: 'bundler', + allowSyntheticDefaultImports: true, + strict: true, + }, + include: ['vite.config.ts'], + }; + + return JSON.stringify(config, null, 2); +} + +function generateViteConfig(): string { + return `import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); +`; +} + +function generateIndexHtml(input: CreateConfigInput): string { + const title = input.appTitle || 'React App'; + return ` + + + + + + ${title} + + +
+ + + +`; +} + +export async function createConfig( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as CreateConfigInput; + + if (!data.configType) { + return { + success: false, + error: 'configType is required', + }; + } + + let content: string; + let filePath: string; + const filesCreated: string[] = []; + + switch (data.configType) { + case 'package.json': + content = generatePackageJson(data); + filePath = path.join(outputDir, 'package.json'); + break; + case 'tsconfig.json': + content = generateTsConfig(); + filePath = path.join(outputDir, 'tsconfig.json'); + // Also create tsconfig.node.json + const nodeConfigPath = path.join(outputDir, 'tsconfig.node.json'); + await writeFile(nodeConfigPath, generateTsConfigNode()); + filesCreated.push(nodeConfigPath); + break; + case 'vite.config.ts': + content = await formatCode(generateViteConfig(), 'typescript'); + filePath = path.join(outputDir, 'vite.config.ts'); + break; + case 'index.html': + content = generateIndexHtml(data); + filePath = path.join(outputDir, 'index.html'); + break; + default: + return { + success: false, + error: `Unknown config type: ${data.configType}`, + }; + } + + await writeFile(filePath, content); + filesCreated.push(filePath); + + return { + success: true, + message: `Created ${data.configType}`, + filesCreated, + }; +} diff --git a/mini-muse/app/lib/tools/generator/createFile.ts b/mini-muse/app/lib/tools/generator/createFile.ts new file mode 100644 index 0000000..6e005d1 --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createFile.ts @@ -0,0 +1,78 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode } from '../../utils'; + +export const createFileSchema = { + type: 'object' as const, + properties: { + filePath: { + type: 'string', + description: 'Path relative to src/ directory (e.g., "types/index.ts", "utils/helpers.ts")', + }, + content: { + type: 'string', + description: 'The complete file content', + }, + fileType: { + type: 'string', + enum: ['typescript', 'css', 'json'], + description: 'File type for formatting', + }, + }, + required: ['filePath', 'content'], +}; + +export interface CreateFileInput { + filePath: string; + content: string; + fileType?: 'typescript' | 'css' | 'json'; +} + +export async function createFile( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as CreateFileInput; + + if (!data.filePath || !data.content) { + return { + success: false, + error: 'filePath and content are required', + }; + } + + // Determine file type from extension if not provided + let fileType = data.fileType; + if (!fileType) { + const ext = path.extname(data.filePath).toLowerCase(); + if (ext === '.ts' || ext === '.tsx') { + fileType = 'typescript'; + } else if (ext === '.css') { + fileType = 'css'; + } else if (ext === '.json') { + fileType = 'json'; + } + } + + // Format code if possible + let content = data.content; + if (fileType) { + content = await formatCode(content, fileType); + } + + // Determine the full path + let fullPath: string; + if (data.filePath.startsWith('src/')) { + fullPath = path.join(outputDir, data.filePath); + } else { + fullPath = path.join(outputDir, 'src', data.filePath); + } + + await writeFile(fullPath, content); + + return { + success: true, + message: `Created file: ${data.filePath}`, + filesCreated: [fullPath], + }; +} diff --git a/mini-muse/app/lib/tools/generator/createHook.ts b/mini-muse/app/lib/tools/generator/createHook.ts new file mode 100644 index 0000000..0c072ec --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createHook.ts @@ -0,0 +1,90 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode, toCamelCase } from '../../utils'; + +export const createHookSchema = { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: 'Hook name starting with "use" (e.g., "useTodos", "useLocalStorage")', + }, + description: { + type: 'string', + description: 'Brief description of what the hook does', + }, + hookCode: { + type: 'string', + description: 'The complete hook code (TypeScript)', + }, + parameters: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + type: { type: 'string' }, + description: { type: 'string' }, + }, + }, + description: 'Hook parameters', + }, + returnType: { + type: 'string', + description: 'TypeScript return type of the hook', + }, + }, + required: ['name', 'hookCode'], +}; + +export interface CreateHookInput { + name: string; + description?: string; + hookCode: string; + parameters?: Array<{ + name: string; + type: string; + description?: string; + }>; + returnType?: string; +} + +export async function createHook( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as CreateHookInput; + + if (!data.name || !data.hookCode) { + return { + success: false, + error: 'name and hookCode are required', + }; + } + + // Ensure hook name starts with "use" + let hookName = toCamelCase(data.name); + if (!hookName.startsWith('use')) { + hookName = 'use' + hookName.charAt(0).toUpperCase() + hookName.slice(1); + } + + const filesCreated: string[] = []; + + // Hook file path + const hookPath = path.join(outputDir, 'src', 'hooks', `${hookName}.ts`); + + // Format and write hook + const formattedHook = await formatCode(data.hookCode, 'typescript'); + await writeFile(hookPath, formattedHook); + filesCreated.push(hookPath); + + return { + success: true, + message: `Created hook: ${hookName}`, + filesCreated, + data: { + hookName, + filePath: hookPath, + }, + }; +} diff --git a/mini-muse/app/lib/tools/generator/createPage.ts b/mini-muse/app/lib/tools/generator/createPage.ts new file mode 100644 index 0000000..11f6132 --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createPage.ts @@ -0,0 +1,92 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode, toPascalCase } from '../../utils'; + +export const createPageSchema = { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: 'Page name in PascalCase (e.g., "HomePage", "SettingsPage")', + }, + routePath: { + type: 'string', + description: 'URL path for the page (e.g., "/", "/settings", "/users/:id")', + }, + description: { + type: 'string', + description: 'Brief description of the page', + }, + pageCode: { + type: 'string', + description: 'The complete React page component code (TSX)', + }, + styleCode: { + type: 'string', + description: 'CSS module styles for the page', + }, + }, + required: ['name', 'routePath', 'pageCode'], +}; + +export interface CreatePageInput { + name: string; + routePath: string; + description?: string; + pageCode: string; + styleCode?: string; +} + +export async function createPage( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as CreatePageInput; + + if (!data.name || !data.routePath || !data.pageCode) { + return { + success: false, + error: 'name, routePath, and pageCode are required', + }; + } + + const pageName = toPascalCase(data.name); + const filesCreated: string[] = []; + + // Page directory + const pageDir = path.join(outputDir, 'src', 'pages', pageName); + + // Format and write page component + const pagePath = path.join(pageDir, `${pageName}.tsx`); + const formattedPage = await formatCode(data.pageCode, 'typescript'); + await writeFile(pagePath, formattedPage); + filesCreated.push(pagePath); + + // Write style file if provided + if (data.styleCode) { + const stylePath = path.join(pageDir, `${pageName}.module.css`); + const formattedStyle = await formatCode(data.styleCode, 'css'); + await writeFile(stylePath, formattedStyle); + filesCreated.push(stylePath); + } + + // Create index.ts barrel export - check if page has default export + const indexPath = path.join(pageDir, 'index.ts'); + const hasDefault = /export\s+default\b/.test(data.pageCode); + const indexContent = hasDefault + ? `export { default } from './${pageName}';\nexport * from './${pageName}';\n` + : `export * from './${pageName}';\n`; + await writeFile(indexPath, indexContent); + filesCreated.push(indexPath); + + return { + success: true, + message: `Created page: ${pageName} (route: ${data.routePath})`, + filesCreated, + data: { + pageName, + routePath: data.routePath, + directory: pageDir, + }, + }; +} diff --git a/mini-muse/app/lib/tools/generator/createStyle.ts b/mini-muse/app/lib/tools/generator/createStyle.ts new file mode 100644 index 0000000..d19f0aa --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createStyle.ts @@ -0,0 +1,63 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode } from '../../utils'; + +export const createStyleSchema = { + type: 'object' as const, + properties: { + fileName: { + type: 'string', + description: 'Style file name (e.g., "global.css", "variables.css", "theme.css")', + }, + styleCode: { + type: 'string', + description: 'The complete CSS code', + }, + directory: { + type: 'string', + description: 'Directory within src/ (default: "styles")', + }, + }, + required: ['fileName', 'styleCode'], +}; + +export interface CreateStyleInput { + fileName: string; + styleCode: string; + directory?: string; +} + +export async function createStyle( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as CreateStyleInput; + + if (!data.fileName || !data.styleCode) { + return { + success: false, + error: 'fileName and styleCode are required', + }; + } + + const filesCreated: string[] = []; + + // Determine directory + const dir = data.directory || 'styles'; + const stylePath = path.join(outputDir, 'src', dir, data.fileName); + + // Format and write style + const formattedStyle = await formatCode(data.styleCode, 'css'); + await writeFile(stylePath, formattedStyle); + filesCreated.push(stylePath); + + return { + success: true, + message: `Created style: ${data.fileName}`, + filesCreated, + data: { + fileName: data.fileName, + filePath: stylePath, + }, + }; +} diff --git a/mini-muse/app/lib/tools/generator/deleteFile.ts b/mini-muse/app/lib/tools/generator/deleteFile.ts new file mode 100644 index 0000000..07314a2 --- /dev/null +++ b/mini-muse/app/lib/tools/generator/deleteFile.ts @@ -0,0 +1,55 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { ToolResult } from '../registry'; + +export const deleteFileSchema = { + type: 'object' as const, + properties: { + filePath: { + type: 'string', + description: 'Path relative to the project root (e.g., "src/components/OldComponent.tsx")', + }, + }, + required: ['filePath'], +}; + +export interface DeleteFileInput { + filePath: string; +} + +export async function deleteFile( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as DeleteFileInput; + + if (!data.filePath) { + return { + success: false, + error: 'filePath is required', + }; + } + + const fullPath = path.resolve(outputDir, data.filePath); + + // Prevent path traversal + if (!fullPath.startsWith(path.resolve(outputDir))) { + return { + success: false, + error: 'Access denied: path traversal detected', + }; + } + + try { + await fs.unlink(fullPath); + return { + success: true, + message: `Deleted: ${data.filePath}`, + }; + } catch { + return { + success: false, + error: `File not found: ${data.filePath}`, + }; + } +} diff --git a/mini-muse/app/lib/tools/reader/readFile.ts b/mini-muse/app/lib/tools/reader/readFile.ts new file mode 100644 index 0000000..4c8ed11 --- /dev/null +++ b/mini-muse/app/lib/tools/reader/readFile.ts @@ -0,0 +1,56 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { ToolResult } from '../registry'; + +export const readFileSchema = { + type: 'object' as const, + properties: { + filePath: { + type: 'string', + description: 'Path relative to the project root (e.g., "src/App.tsx", "package.json")', + }, + }, + required: ['filePath'], +}; + +export interface ReadFileInput { + filePath: string; +} + +export async function readProjectFile( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as ReadFileInput; + + if (!data.filePath) { + return { + success: false, + error: 'filePath is required', + }; + } + + const fullPath = path.resolve(outputDir, data.filePath); + + // Prevent path traversal + if (!fullPath.startsWith(path.resolve(outputDir))) { + return { + success: false, + error: 'Access denied: path traversal detected', + }; + } + + try { + const content = await fs.readFile(fullPath, 'utf-8'); + return { + success: true, + message: `Read file: ${data.filePath} (${content.length} chars)`, + data: { content }, + }; + } catch { + return { + success: false, + error: `File not found: ${data.filePath}`, + }; + } +} diff --git a/mini-muse/app/lib/tools/registry.ts b/mini-muse/app/lib/tools/registry.ts new file mode 100644 index 0000000..fb4ee92 --- /dev/null +++ b/mini-muse/app/lib/tools/registry.ts @@ -0,0 +1,147 @@ +import { analyzeRequirements, analyzeRequirementsSchema } from './analyzer/analyzeRequirements'; +import { planArchitecture, planArchitectureSchema } from './analyzer/planArchitecture'; +import { createConfig, createConfigSchema } from './generator/createConfig'; +import { createFile, createFileSchema } from './generator/createFile'; +import { createComponent, createComponentSchema } from './generator/createComponent'; +import { createPage, createPageSchema } from './generator/createPage'; +import { createHook, createHookSchema } from './generator/createHook'; +import { createStyle, createStyleSchema } from './generator/createStyle'; +import { validateProject, validateProjectSchema } from './validator/validateProject'; +import { readProjectFile, readFileSchema } from './reader/readFile'; +import { deleteFile, deleteFileSchema } from './generator/deleteFile'; + +export interface ToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +export type ToolName = + | 'analyze_requirements' + | 'plan_architecture' + | 'create_config' + | 'create_file' + | 'create_component' + | 'create_page' + | 'create_hook' + | 'create_style' + | 'validate_project' + | 'read_file' + | 'delete_file'; + +export interface ToolResult { + success: boolean; + message?: string; + data?: unknown; + filesCreated?: string[]; + error?: string; +} + +type ToolExecutor = (input: Record, outputDir: string) => Promise; + +const toolExecutors: Record = { + analyze_requirements: analyzeRequirements, + plan_architecture: planArchitecture, + create_config: createConfig, + create_file: createFile, + create_component: createComponent, + create_page: createPage, + create_hook: createHook, + create_style: createStyle, + validate_project: validateProject, + read_file: readProjectFile, + delete_file: deleteFile, +}; + +const toolSchemas: Record = { + analyze_requirements: { + name: 'analyze_requirements', + description: + 'Analyze user requirements and extract structured information about the application. This should be the first tool called to understand what needs to be built.', + input_schema: analyzeRequirementsSchema, + }, + plan_architecture: { + name: 'plan_architecture', + description: + 'Plan the application architecture based on analyzed requirements. Creates a detailed file structure and component hierarchy.', + input_schema: planArchitectureSchema, + }, + create_config: { + name: 'create_config', + description: + 'Create project configuration files like package.json, tsconfig.json, vite.config.ts, and index.html.', + input_schema: createConfigSchema, + }, + create_file: { + name: 'create_file', + description: + 'Create a generic file with specified content. Use for type definitions, utilities, constants, or any file that does not fit other specialized tools.', + input_schema: createFileSchema, + }, + create_component: { + name: 'create_component', + description: + 'Create a React component with its associated CSS module. The component will be created as a functional component with TypeScript.', + input_schema: createComponentSchema, + }, + create_page: { + name: 'create_page', + description: + 'Create a page component. Pages are top-level components that represent routes in the application.', + input_schema: createPageSchema, + }, + create_hook: { + name: 'create_hook', + description: + 'Create a custom React hook for reusable stateful logic.', + input_schema: createHookSchema, + }, + create_style: { + name: 'create_style', + description: + 'Create global styles, CSS variables, or theme files.', + input_schema: createStyleSchema, + }, + validate_project: { + name: 'validate_project', + description: + 'Validate the generated project for completeness and correctness. Checks that all required files exist and imports resolve.', + input_schema: validateProjectSchema, + }, + read_file: { + name: 'read_file', + description: + 'Read the content of an existing file in the project. Use this to understand current code before making modifications.', + input_schema: readFileSchema, + }, + delete_file: { + name: 'delete_file', + description: + 'Delete a file from the project. Use this to remove files that are no longer needed.', + input_schema: deleteFileSchema, + }, +}; + +export function getToolDefinitions(): ToolDefinition[] { + return Object.values(toolSchemas); +} + +export async function executeTool( + name: ToolName, + input: Record, + outputDir: string +): Promise { + const executor = toolExecutors[name]; + if (!executor) { + return { + success: false, + error: `Unknown tool: ${name}`, + }; + } + + return executor(input, outputDir); +} diff --git a/mini-muse/app/lib/tools/validator/validateProject.ts b/mini-muse/app/lib/tools/validator/validateProject.ts new file mode 100644 index 0000000..00d800d --- /dev/null +++ b/mini-muse/app/lib/tools/validator/validateProject.ts @@ -0,0 +1,235 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { fileExists, listFiles, readFile } from '../../utils'; + +export const validateProjectSchema = { + type: 'object' as const, + properties: { + checks: { + type: 'array', + items: { + type: 'string', + enum: ['files', 'imports', 'types', 'structure'], + }, + description: 'Types of validation to perform', + }, + }, + required: [], +}; + +export interface ValidateProjectInput { + checks?: Array<'files' | 'imports' | 'types' | 'structure'>; +} + +interface ValidationResult { + check: string; + passed: boolean; + message: string; + details?: string[]; +} + +async function validateRequiredFiles(outputDir: string): Promise { + const requiredFiles = [ + 'package.json', + 'tsconfig.json', + 'vite.config.ts', + 'index.html', + 'src/main.tsx', + 'src/App.tsx', + ]; + + const missing: string[] = []; + for (const file of requiredFiles) { + const filePath = path.join(outputDir, file); + if (!(await fileExists(filePath))) { + missing.push(file); + } + } + + return { + check: 'Required Files', + passed: missing.length === 0, + message: missing.length === 0 ? 'All required files present' : `Missing ${missing.length} required files`, + details: missing.length > 0 ? missing : undefined, + }; +} + +async function validateProjectStructure(outputDir: string): Promise { + const requiredDirs = ['src']; + const recommendedDirs = ['src/components', 'src/pages']; + + const missingRequired: string[] = []; + const missingRecommended: string[] = []; + + for (const dir of requiredDirs) { + const dirPath = path.join(outputDir, dir); + if (!(await fileExists(dirPath))) { + missingRequired.push(dir); + } + } + + for (const dir of recommendedDirs) { + const dirPath = path.join(outputDir, dir); + if (!(await fileExists(dirPath))) { + missingRecommended.push(dir); + } + } + + const passed = missingRequired.length === 0; + const details: string[] = []; + + if (missingRequired.length > 0) { + details.push(`Missing required: ${missingRequired.join(', ')}`); + } + if (missingRecommended.length > 0) { + details.push(`Missing recommended: ${missingRecommended.join(', ')}`); + } + + return { + check: 'Project Structure', + passed, + message: passed ? 'Project structure is valid' : 'Invalid project structure', + details: details.length > 0 ? details : undefined, + }; +} + +async function validateImports(outputDir: string): Promise { + const srcDir = path.join(outputDir, 'src'); + const issues: string[] = []; + + try { + const files = await listFiles(srcDir); + const tsFiles = files.filter((f) => f.endsWith('.ts') || f.endsWith('.tsx')); + + for (const file of tsFiles) { + const content = await readFile(file); + const importMatches = content.matchAll(/import\s+.*?\s+from\s+['"]([^'"]+)['"]/g); + + for (const match of importMatches) { + const importPath = match[1]; + + // Skip external packages + if (!importPath.startsWith('.') && !importPath.startsWith('@/')) { + continue; + } + + // Check relative imports + if (importPath.startsWith('.')) { + const baseDir = path.dirname(file); + const targetPath = path.resolve(baseDir, importPath); + + // Try with extensions + const extensions = ['', '.ts', '.tsx', '/index.ts', '/index.tsx']; + let found = false; + + for (const ext of extensions) { + if (await fileExists(targetPath + ext)) { + found = true; + break; + } + } + + if (!found) { + const relativePath = path.relative(outputDir, file); + issues.push(`${relativePath}: Cannot resolve '${importPath}'`); + } + } + } + } + } catch (error) { + return { + check: 'Imports', + passed: false, + message: 'Failed to validate imports', + details: [String(error)], + }; + } + + return { + check: 'Imports', + passed: issues.length === 0, + message: issues.length === 0 ? 'All imports resolve correctly' : `Found ${issues.length} import issues`, + details: issues.length > 0 ? issues.slice(0, 10) : undefined, // Limit to first 10 + }; +} + +async function validateTypeAnnotations(outputDir: string): Promise { + const srcDir = path.join(outputDir, 'src'); + const issues: string[] = []; + + try { + const files = await listFiles(srcDir); + const tsFiles = files.filter((f) => f.endsWith('.ts') || f.endsWith('.tsx')); + + for (const file of tsFiles) { + const content = await readFile(file); + + // Check for 'any' type usage + const anyMatches = content.match(/:\s*any\b/g); + if (anyMatches && anyMatches.length > 0) { + const relativePath = path.relative(outputDir, file); + issues.push(`${relativePath}: Contains ${anyMatches.length} 'any' type(s)`); + } + } + } catch (error) { + return { + check: 'Type Annotations', + passed: false, + message: 'Failed to validate types', + details: [String(error)], + }; + } + + return { + check: 'Type Annotations', + passed: issues.length === 0, + message: issues.length === 0 ? 'No unsafe type annotations found' : `Found ${issues.length} type issues`, + details: issues.length > 0 ? issues : undefined, + }; +} + +export async function validateProject( + input: Record, + outputDir: string +): Promise { + const data = input as unknown as ValidateProjectInput; + const checks = data.checks || ['files', 'imports', 'types', 'structure']; + + const results: ValidationResult[] = []; + + for (const check of checks) { + switch (check) { + case 'files': + results.push(await validateRequiredFiles(outputDir)); + break; + case 'imports': + results.push(await validateImports(outputDir)); + break; + case 'types': + results.push(await validateTypeAnnotations(outputDir)); + break; + case 'structure': + results.push(await validateProjectStructure(outputDir)); + break; + } + } + + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + const allPassed = failed === 0; + + // Build summary + const summary = results.map((r) => `${r.passed ? '✓' : '✗'} ${r.check}: ${r.message}`).join('\n'); + + return { + success: allPassed, + message: `Validation complete: ${passed}/${results.length} checks passed`, + data: { + results, + summary, + passed, + failed, + total: results.length, + }, + }; +} diff --git a/mini-muse/app/lib/utils.ts b/mini-muse/app/lib/utils.ts new file mode 100644 index 0000000..e1d12ae --- /dev/null +++ b/mini-muse/app/lib/utils.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import prettier from 'prettier'; + +export async function ensureDir(dirPath: string): Promise { + await fs.mkdir(dirPath, { recursive: true }); +} + +export async function writeFile(filePath: string, content: string): Promise { + const dir = path.dirname(filePath); + await ensureDir(dir); + await fs.writeFile(filePath, content, 'utf-8'); +} + +export async function readFile(filePath: string): Promise { + return fs.readFile(filePath, 'utf-8'); +} + +export async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function listFiles(dirPath: string): Promise { + const files: string[] = []; + + async function walk(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && entry.name !== '.git') { + await walk(fullPath); + } + } else { + files.push(fullPath); + } + } + } + + await walk(dirPath); + return files; +} + +export async function formatCode(code: string, parser: 'typescript' | 'css' | 'json' | 'html'): Promise { + try { + return await prettier.format(code, { + parser, + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', + printWidth: 100, + }); + } catch { + return code; + } +} + +export function toKebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase(); +} + +export function toPascalCase(str: string): string { + return str + .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')) + .replace(/^(.)/, (c) => c.toUpperCase()); +} + +export function toCamelCase(str: string): string { + const pascal = toPascalCase(str); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +export function indent(text: string, spaces: number = 2): string { + const padding = ' '.repeat(spaces); + return text + .split('\n') + .map((line) => (line.trim() ? padding + line : line)) + .join('\n'); +} diff --git a/mini-muse/app/middleware/errorHandler.ts b/mini-muse/app/middleware/errorHandler.ts new file mode 100644 index 0000000..2c20819 --- /dev/null +++ b/mini-muse/app/middleware/errorHandler.ts @@ -0,0 +1,24 @@ +import { Context } from 'egg'; + +export default () => { + return async function errorHandler(ctx: Context, next: () => Promise) { + try { + await next(); + } catch (err: unknown) { + const error = err as Error & { status?: number }; + + // Only handle API errors + if (!ctx.path.startsWith('/api')) { + throw err; + } + + ctx.logger.error(error); + + ctx.status = error.status || 500; + ctx.body = { + success: false, + error: error.message || 'Internal Server Error', + }; + } + }; +}; diff --git a/mini-muse/app/router.ts b/mini-muse/app/router.ts new file mode 100644 index 0000000..548613b --- /dev/null +++ b/mini-muse/app/router.ts @@ -0,0 +1,20 @@ +import { Application } from 'egg'; + +export default (app: Application) => { + const { controller, router } = app; + + // API routes + router.post('/api/generate', controller.generate.create); + router.get('/api/generate/:taskId/status', controller.generate.status); + router.get('/api/generate/:taskId/files', controller.generate.files); + router.get('/api/generate/:taskId/file', controller.generate.fileContent); + router.get('/api/generate/:taskId/download', controller.generate.download); + router.post('/api/generate/:taskId/preview', controller.generate.startPreview); + router.get('/api/generate/:taskId/preview', controller.generate.previewStatus); + router.post('/api/generate/:taskId/modify', controller.generate.modify); + router.get('/api/generate/:taskId/chat', controller.generate.chatHistory); + router.get('/api/tasks', controller.generate.list); + + // SPA fallback - serve index.html for non-API routes + router.get('*', controller.home.index); +}; diff --git a/mini-muse/app/service/aiClient.ts b/mini-muse/app/service/aiClient.ts new file mode 100644 index 0000000..656465e --- /dev/null +++ b/mini-muse/app/service/aiClient.ts @@ -0,0 +1,98 @@ +import { Service } from 'egg'; +import Anthropic from '@anthropic-ai/sdk'; + +export type MessageRole = 'user' | 'assistant'; + +export interface TextContent { + type: 'text'; + text: string; +} + +export interface ToolUseContent { + type: 'tool_use'; + id: string; + name: string; + input: Record; +} + +export interface ToolResultContent { + type: 'tool_result'; + tool_use_id: string; + content: string; + is_error?: boolean; +} + +export type ContentBlock = TextContent | ToolUseContent | ToolResultContent; + +export interface Message { + role: MessageRole; + content: string | ContentBlock[]; +} + +export interface ToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +export interface CreateMessageParams { + model?: string; + maxTokens?: number; + system?: string; + messages: Message[]; + tools?: ToolDefinition[]; +} + +export interface MessageResponse { + id: string; + type: 'message'; + role: 'assistant'; + content: Array<{ + type: 'text'; + text: string; + } | { + type: 'tool_use'; + id: string; + name: string; + input: Record; + }>; + stop_reason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence'; + usage: { + input_tokens: number; + output_tokens: number; + }; +} + +export default class AiClientService extends Service { + private client: Anthropic | null = null; + + private getClient(): Anthropic { + if (!this.client) { + this.client = new Anthropic({ + baseURL: process.env.ANTHROPIC_BASE_URL || undefined, + authToken: process.env.ANTHROPIC_AUTH_TOKEN || undefined, + apiKey: process.env.ANTHROPIC_API_KEY || undefined, + }); + } + return this.client; + } + + async createMessage(params: CreateMessageParams): Promise { + const client = this.getClient(); + const { miniMuse } = this.config; + + const response = await client.messages.create({ + model: params.model ?? miniMuse.model, + max_tokens: params.maxTokens ?? miniMuse.maxTokens, + system: params.system, + messages: params.messages as Anthropic.MessageParam[], + tools: params.tools as Anthropic.Tool[], + }); + + return response as unknown as MessageResponse; + } +} diff --git a/mini-muse/app/service/orchestrator.ts b/mini-muse/app/service/orchestrator.ts new file mode 100644 index 0000000..7ec50be --- /dev/null +++ b/mini-muse/app/service/orchestrator.ts @@ -0,0 +1,262 @@ +import { Service } from 'egg'; +import * as path from 'path'; +import { SYSTEM_PROMPT, MODIFY_SYSTEM_PROMPT, createUserPrompt, createModifyPrompt } from '../lib/prompts'; +import { Message, ContentBlock, ToolResultContent } from './aiClient'; +import { ToolName } from '../lib/tools/registry'; +import { listFiles } from '../lib/utils'; + +export default class OrchestratorService extends Service { + async run(taskId: string, description: string): Promise { + const { ctx } = this; + const task = ctx.service.taskManager.getTask(taskId); + + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + const messages: Message[] = [{ + role: 'user', + content: createUserPrompt(description), + }]; + + ctx.service.taskManager.updateProgress(taskId, { + type: 'status', + message: 'Starting generation...', + timestamp: Date.now(), + }); + + // Save initial chat history entry + task.chatHistory.push({ + role: 'user', + content: description, + timestamp: Date.now(), + }); + + await this.agentLoop(taskId, messages, SYSTEM_PROMPT); + + // Save messages to task for future modifications + task.messages = messages; + + // Add assistant summary to chat history + if (task.status === 'completed') { + task.chatHistory.push({ + role: 'assistant', + content: this.extractSummary(messages), + timestamp: Date.now(), + }); + } + } + + async modify(taskId: string, instruction: string): Promise { + const { ctx } = this; + const task = ctx.service.taskManager.getTask(taskId); + + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + // Read existing file list for context + let relativeFiles: string[] = []; + try { + const fileList = await listFiles(task.outputDir); + relativeFiles = fileList.map(f => path.relative(task.outputDir, f)); + } catch { + // Directory might not exist yet + } + + // Restore previous messages and append modification instruction + const messages: Message[] = [...task.messages as Message[]]; + messages.push({ + role: 'user', + content: createModifyPrompt(instruction, relativeFiles), + }); + + ctx.service.taskManager.updateProgress(taskId, { + type: 'modify_started', + message: 'Starting modification...', + timestamp: Date.now(), + }); + + await this.agentLoop(taskId, messages, MODIFY_SYSTEM_PROMPT); + + // Save updated messages back to task + task.messages = messages; + + // Add assistant summary to chat history + if (task.status === 'completed') { + const summary = this.extractSummary(messages); + task.chatHistory.push({ + role: 'assistant', + content: summary, + timestamp: Date.now(), + }); + + ctx.service.taskManager.updateProgress(taskId, { + type: 'modify_completed', + message: summary, + timestamp: Date.now(), + }); + } + } + + private async agentLoop(taskId: string, messages: Message[], systemPrompt: string): Promise { + const { ctx } = this; + const { miniMuse } = this.config; + const toolDefinitions = ctx.service.tools.getDefinitions(); + const task = ctx.service.taskManager.getTask(taskId); + + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + let iterations = 0; + const maxIterations = miniMuse.maxIterations; + + try { + while (iterations < maxIterations) { + iterations++; + + ctx.service.taskManager.updateProgress(taskId, { + type: 'status', + message: `Processing (iteration ${iterations})...`, + timestamp: Date.now(), + }); + + const response = await ctx.service.aiClient.createMessage({ + system: systemPrompt, + messages, + tools: toolDefinitions, + }); + + messages.push({ + role: 'assistant', + content: response.content as ContentBlock[], + }); + + for (const block of response.content) { + if (block.type === 'text') { + ctx.service.taskManager.updateProgress(taskId, { + type: 'thinking', + message: block.text.slice(0, 500) + (block.text.length > 500 ? '...' : ''), + timestamp: Date.now(), + }); + } + } + + if (response.stop_reason === 'end_turn') { + ctx.service.taskManager.updateProgress(taskId, { + type: 'completed', + message: 'Generation complete!', + timestamp: Date.now(), + }); + break; + } + + if (response.stop_reason === 'tool_use') { + const toolResults = await this.processToolCalls(taskId, response.content, task.outputDir); + messages.push({ + role: 'user', + content: toolResults, + }); + } + } + + if (iterations >= maxIterations) { + ctx.service.taskManager.updateProgress(taskId, { + type: 'error', + message: `Exceeded maximum iterations (${maxIterations})`, + timestamp: Date.now(), + }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + ctx.service.taskManager.updateProgress(taskId, { + type: 'error', + message: errorMessage, + timestamp: Date.now(), + }); + } + } + + private extractSummary(messages: Message[]): string { + // Find the last assistant text message as summary + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === 'assistant' && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + return block.text.slice(0, 500); + } + } + } + } + return 'Task completed.'; + } + + private async processToolCalls( + taskId: string, + content: Array<{ type: string; id?: string; name?: string; input?: Record }>, + outputDir: string + ): Promise { + const { ctx } = this; + const results: ToolResultContent[] = []; + + for (const block of content) { + if (block.type === 'tool_use' && block.id && block.name) { + const toolName = block.name as ToolName; + const input = block.input || {}; + + ctx.service.taskManager.updateProgress(taskId, { + type: 'tool_call', + message: `Executing: ${toolName}`, + data: { tool: toolName, input: JSON.stringify(input).slice(0, 200) }, + timestamp: Date.now(), + }); + + try { + const result = await ctx.service.tools.execute(toolName, input, outputDir); + + if (result.filesCreated && result.filesCreated.length > 0) { + ctx.service.taskManager.updateProgress(taskId, { + type: 'file_created', + message: `Created: ${result.filesCreated.map(f => f.replace(outputDir + '/', '')).join(', ')}`, + data: result.filesCreated, + timestamp: Date.now(), + }); + } + + ctx.service.taskManager.updateProgress(taskId, { + type: 'tool_result', + message: result.message || `${toolName} completed`, + data: { tool: toolName, success: result.success }, + timestamp: Date.now(), + }); + + results.push({ + type: 'tool_result', + tool_use_id: block.id, + content: JSON.stringify(result), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + ctx.service.taskManager.updateProgress(taskId, { + type: 'tool_result', + message: `Error in ${toolName}: ${errorMessage}`, + data: { tool: toolName, success: false }, + timestamp: Date.now(), + }); + + results.push({ + type: 'tool_result', + tool_use_id: block.id, + content: JSON.stringify({ error: errorMessage }), + is_error: true, + }); + } + } + } + + return results; + } +} diff --git a/mini-muse/app/service/preview.ts b/mini-muse/app/service/preview.ts new file mode 100644 index 0000000..ddcd1ee --- /dev/null +++ b/mini-muse/app/service/preview.ts @@ -0,0 +1,146 @@ +import { Service } from 'egg'; +import { spawn, ChildProcess } from 'child_process'; +import * as net from 'net'; + +interface PreviewInfo { + taskId: string; + status: 'installing' | 'starting' | 'running' | 'failed'; + port?: number; + error?: string; + process?: ChildProcess; +} + +const previews = new Map(); + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, () => { + const addr = srv.address(); + if (addr && typeof addr === 'object') { + const port = addr.port; + srv.close(() => resolve(port)); + } else { + reject(new Error('Failed to find free port')); + } + }); + }); +} + +export default class PreviewService extends Service { + async start(taskId: string): Promise { + // If already running, return existing info + const existing = previews.get(taskId); + if (existing && (existing.status === 'running' || existing.status === 'installing' || existing.status === 'starting')) { + return existing; + } + + const task = this.ctx.service.taskManager.getTask(taskId); + if (!task) { + throw new Error('Task not found'); + } + if (task.status !== 'completed') { + throw new Error('Task is not completed yet'); + } + + const info: PreviewInfo = { + taskId, + status: 'installing', + }; + previews.set(taskId, info); + + // Run async - don't await + this.installAndStart(taskId, task.outputDir, info).catch(err => { + this.ctx.logger.error('Preview error for task %s: %s', taskId, err.message); + }); + + return info; + } + + getStatus(taskId: string): PreviewInfo | undefined { + const info = previews.get(taskId); + if (!info) return undefined; + // Don't leak the process object + return { + taskId: info.taskId, + status: info.status, + port: info.port, + error: info.error, + }; + } + + stop(taskId: string): void { + const info = previews.get(taskId); + if (info?.process) { + info.process.kill('SIGTERM'); + info.status = 'failed'; + previews.delete(taskId); + } + } + + private async installAndStart(taskId: string, outputDir: string, info: PreviewInfo): Promise { + // Step 1: npm install + await new Promise((resolve, reject) => { + const child = spawn('npm', ['install'], { + cwd: outputDir, + stdio: 'pipe', + env: { ...process.env, npm_config_registry: 'https://registry.npmmirror.com' }, + }); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm install failed with code ${code}`)); + }); + child.on('error', reject); + }); + + // Step 2: Find a free port and start vite dev server + info.status = 'starting'; + const port = await findFreePort(); + info.port = port; + + const child = spawn('npx', ['vite', '--port', String(port), '--host', '0.0.0.0'], { + cwd: outputDir, + stdio: 'pipe', + env: { ...process.env }, + }); + + info.process = child; + + // Wait for vite to be ready + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // Assume it's running after 10s even without the ready message + info.status = 'running'; + resolve(); + }, 10000); + + const onData = (data: Buffer) => { + const output = data.toString(); + if (output.includes('Local:') || output.includes('ready in')) { + clearTimeout(timer); + info.status = 'running'; + resolve(); + } + }; + + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + + child.on('error', (err) => { + clearTimeout(timer); + info.status = 'failed'; + info.error = err.message; + reject(err); + }); + + child.on('close', (code) => { + if (info.status !== 'running') { + clearTimeout(timer); + info.status = 'failed'; + info.error = `Vite exited with code ${code}`; + reject(new Error(info.error)); + } + }); + }); + } +} diff --git a/mini-muse/app/service/taskManager.ts b/mini-muse/app/service/taskManager.ts new file mode 100644 index 0000000..ca3a92f --- /dev/null +++ b/mini-muse/app/service/taskManager.ts @@ -0,0 +1,100 @@ +import { Service } from 'egg'; +import { EventEmitter } from 'events'; +import * as crypto from 'crypto'; + +export interface ProgressEvent { + type: 'status' | 'thinking' | 'tool_call' | 'tool_result' | 'file_created' | 'completed' | 'error' | 'modify_started' | 'modify_completed'; + message: string; + data?: unknown; + timestamp: number; +} + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; +} + +export interface TaskInfo { + id: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + description: string; + appName: string; + outputDir: string; + filesCreated: string[]; + progress: ProgressEvent[]; + messages: unknown[]; + chatHistory: ChatMessage[]; + error?: string; + createdAt: number; +} + +// Singleton emitter shared across all service instances +const globalEmitter = new EventEmitter(); +globalEmitter.setMaxListeners(100); + +// Singleton task store shared across all service instances +const tasks = new Map(); + +export default class TaskManagerService extends Service { + createTask(description: string, appName: string): TaskInfo { + const id = crypto.randomUUID(); + const { miniMuse } = this.config; + const outputDir = `${miniMuse.outputDir}/${id}`; + + const task: TaskInfo = { + id, + status: 'pending', + description, + appName, + outputDir, + filesCreated: [], + progress: [], + messages: [], + chatHistory: [], + createdAt: Date.now(), + }; + + tasks.set(id, task); + return task; + } + + getTask(taskId: string): TaskInfo | undefined { + return tasks.get(taskId); + } + + listTasks(): TaskInfo[] { + return Array.from(tasks.values()).sort((a, b) => b.createdAt - a.createdAt); + } + + updateProgress(taskId: string, event: ProgressEvent): void { + const task = tasks.get(taskId); + if (!task) return; + + task.progress.push(event); + + if (event.type === 'completed') { + task.status = 'completed'; + } else if (event.type === 'error') { + task.status = 'failed'; + task.error = event.message; + } else if (task.status === 'pending') { + task.status = 'running'; + } + + if (event.type === 'file_created' && event.data) { + const files = event.data as string[]; + task.filesCreated.push(...files); + } + + globalEmitter.emit(`task:${taskId}`, event); + } + + subscribe(taskId: string, listener: (event: ProgressEvent) => void): () => void { + const eventName = `task:${taskId}`; + globalEmitter.on(eventName, listener); + return () => { + globalEmitter.off(eventName, listener); + }; + } +} diff --git a/mini-muse/app/service/tools.ts b/mini-muse/app/service/tools.ts new file mode 100644 index 0000000..830835a --- /dev/null +++ b/mini-muse/app/service/tools.ts @@ -0,0 +1,13 @@ +import { Service } from 'egg'; +import { getToolDefinitions, executeTool, ToolName, ToolResult } from '../lib/tools/registry'; +import { ToolDefinition } from './aiClient'; + +export default class ToolsService extends Service { + getDefinitions(): ToolDefinition[] { + return getToolDefinitions(); + } + + async execute(name: string, input: Record, outputDir: string): Promise { + return executeTool(name as ToolName, input, outputDir); + } +} diff --git a/mini-muse/config/config.default.ts b/mini-muse/config/config.default.ts new file mode 100644 index 0000000..5d76018 --- /dev/null +++ b/mini-muse/config/config.default.ts @@ -0,0 +1,33 @@ +import { EggAppConfig, PowerPartial } from 'egg'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default () => { + const config = {} as PowerPartial; + + config.keys = 'mini-muse-secret-key'; + + config.miniMuse = { + outputDir: './output', + maxIterations: 50, + model: 'claude-sonnet-4-20250514', + maxTokens: 8192, + }; + + config.security = { + csrf: { + enable: false, + }, + }; + + config.static = { + prefix: '/', + }; + + config.bodyParser = { + jsonLimit: '1mb', + }; + + return config; +}; diff --git a/mini-muse/config/config.local.ts b/mini-muse/config/config.local.ts new file mode 100644 index 0000000..3c733a0 --- /dev/null +++ b/mini-muse/config/config.local.ts @@ -0,0 +1,6 @@ +import { EggAppConfig, PowerPartial } from 'egg'; + +export default () => { + const config = {} as PowerPartial; + return config; +}; diff --git a/mini-muse/config/plugin.ts b/mini-muse/config/plugin.ts new file mode 100644 index 0000000..2b5494a --- /dev/null +++ b/mini-muse/config/plugin.ts @@ -0,0 +1,7 @@ +import { EggPlugin } from 'egg'; + +const plugin: EggPlugin = { + // egg-static is already enabled by default in egg v3 +}; + +export default plugin; diff --git a/mini-muse/docs/design.md b/mini-muse/docs/design.md new file mode 100644 index 0000000..ae105d8 --- /dev/null +++ b/mini-muse/docs/design.md @@ -0,0 +1,316 @@ +# Mini-Muse 设计文档 + +## 1. 项目概述 + +Mini-Muse 是一个 AI 驱动的 Web 应用生成器。用户通过 Web 界面描述想要的应用,AI(Claude)自动生成完整的 React + TypeScript 项目代码,支持实时进度查看、代码浏览、在线预览和 ZIP 下载。 + +### 核心功能 + +- 输入自然语言描述,AI 自动生成完整 React + TypeScript 项目 +- SSE 实时推送生成进度 +- 生成结果的文件树浏览与代码预览 +- 一键启动生成项目的本地预览(Vite dev server) +- ZIP 打包下载 +- **Human-in-the-Loop 迭代修改**:生成完成后通过聊天面板与 AI 对话,增量修改已生成的应用 + +--- + +## 2. 系统架构 + +```mermaid +graph TB + subgraph Frontend["前端 (React SPA)"] + HP[HomePage
输入表单] + PP[ProgressPage
SSE 进度展示] + RP[ResultPage
代码浏览 + 预览] + end + + subgraph Backend["后端 (Egg.js)"] + Router[Router] + GC[GenerateController] + HC[HomeController] + + subgraph Services + TM[TaskManager
任务管理 + EventEmitter] + ORC[Orchestrator
Agent 主循环] + AI[AiClient
Claude SDK] + TS[ToolsService
工具调用] + PV[PreviewService
预览服务器管理] + end + + subgraph Lib["app/lib (纯业务逻辑)"] + PR[prompts.ts] + UT[utils.ts] + subgraph Tools["tools/"] + AN[analyzer/
需求分析 + 架构规划] + GN[generator/
代码生成 x6] + VL[validator/
项目验证] + end + end + end + + subgraph External + Claude[Claude API] + FS[文件系统
output/] + end + + HP -->|POST /api/generate| Router + PP -->|GET /api/generate/:id/status
SSE| Router + RP -->|GET files/file/download
POST preview| Router + + Router --> GC + Router --> HC + + GC --> TM + GC --> ORC + GC --> PV + ORC --> AI + ORC --> TS + AI --> Claude + TS --> Tools + Tools --> UT + ORC --> PR + Tools --> FS + PV --> FS +``` + +--- + +## 3. 技术栈 + +| 层级 | 技术 | +|------|------| +| 后端框架 | Egg.js 3 (TypeScript) | +| AI SDK | @anthropic-ai/sdk | +| 前端框架 | React 18 + React Router v6 | +| 构建工具 | Vite 5 | +| 样式方案 | Tailwind CSS 3 | +| 代码格式化 | Prettier | +| 数据验证 | Zod | +| 压缩下载 | archiver | + +--- + +## 4. 后端模块详解 + +### 4.1 路由 (`app/router.ts`) + +| 方法 | 路由 | 说明 | +|------|------|------| +| POST | `/api/generate` | 创建生成任务 | +| GET | `/api/generate/:taskId/status` | SSE 实时进度流 | +| GET | `/api/generate/:taskId/files` | 获取文件列表 | +| GET | `/api/generate/:taskId/file?path=xxx` | 获取文件内容 | +| GET | `/api/generate/:taskId/download` | ZIP 下载 | +| POST | `/api/generate/:taskId/preview` | 启动预览服务器 | +| GET | `/api/generate/:taskId/preview` | 查询预览状态 | +| POST | `/api/generate/:taskId/modify` | 发起迭代修改 | +| GET | `/api/generate/:taskId/chat` | 获取聊天历史 | +| GET | `/api/tasks` | 任务列表 | +| GET | `*` | SPA fallback | + +### 4.2 Service 层 + +#### TaskManager (`app/service/taskManager.ts`) + +- 内存 Map 存储任务: `Map` +- TaskInfo 包含: id, status, description, appName, outputDir, filesCreated, progress[], messages[], chatHistory[], error +- ChatMessage 接口: { role: 'user' | 'assistant', content: string, timestamp: number } +- ProgressEvent 类型: status / thinking / tool_call / tool_result / file_created / completed / error / modify_started / modify_completed +- 通过全局 EventEmitter 实现 SSE 订阅通知 + +#### Orchestrator (`app/service/orchestrator.ts`) + +Agent 主循环,支持初始生成和迭代修改两种模式: + +- `run(taskId, description)`: 初始生成,使用 SYSTEM_PROMPT +- `modify(taskId, instruction)`: 迭代修改,使用 MODIFY_SYSTEM_PROMPT,从 task.messages 恢复对话上下文 +- 两者共享 `agentLoop(taskId, messages, systemPrompt)` 私有方法 +- 循环结束后将 messages 保存到 task.messages,支持多轮修改 + +```mermaid +sequenceDiagram + participant C as Controller + participant O as Orchestrator + participant AI as Claude API + participant T as Tools + participant TM as TaskManager + + C->>O: run(taskId, description) / modify(taskId, instruction) + O->>TM: updateProgress(status) + + loop agentLoop: iterations < maxIterations + O->>AI: createMessage(messages, tools) + AI-->>O: response + + alt stop_reason == end_turn + O->>TM: updateProgress(completed) + else stop_reason == tool_use + O->>T: executeTool(name, input) + T-->>O: result + O->>TM: updateProgress(tool_result/file_created) + Note over O: 将结果追加到 messages 继续循环 + end + end + + O->>TM: 保存 messages 到 task.messages + O->>TM: 保存摘要到 task.chatHistory +``` + +#### AiClient (`app/service/aiClient.ts`) + +- 封装 Anthropic SDK +- 支持自定义 `baseURL`(通过 `ANTHROPIC_BASE_URL` 环境变量) +- 支持 Bearer Token 认证(通过 `ANTHROPIC_AUTH_TOKEN` 环境变量) +- model 和 maxTokens 从 `config.miniMuse` 读取 + +#### PreviewService (`app/service/preview.ts`) + +- 管理生成项目的 Vite dev server 生命周期 +- 流程: npm install → 分配空闲端口 → 启动 vite dev server +- 通过 `Map` 跟踪预览状态 +- 支持 installing / starting / running / failed 状态查询 + +### 4.3 纯业务逻辑 (`app/lib/`) + +从原 CLI 版本搬迁,不依赖 Egg.js 框架: + +| 文件 | 说明 | +|------|------| +| `prompts.ts` | Claude 系统提示词 (SYSTEM_PROMPT + MODIFY_SYSTEM_PROMPT) + 用户提示词模板 (createUserPrompt + createModifyPrompt) | +| `utils.ts` | 文件操作、Prettier 格式化、命名转换工具 | +| `tools/registry.ts` | 工具注册表 (11 个工具的定义和执行入口) | +| `tools/analyzer/` | analyzeRequirements + planArchitecture | +| `tools/generator/` | createConfig / createFile / createComponent / createPage / createHook / createStyle / deleteFile | +| `tools/reader/` | readFile (读取已生成项目文件,用于增量修改) | +| `tools/validator/` | validateProject (文件完整性、导入、类型检查) | + +--- + +## 5. 前端结构 + +### 5.1 页面 + +``` +frontend/src/ +├── pages/ +│ ├── HomePage.tsx # 输入表单 (appName + description) +│ ├── ProgressPage.tsx # SSE 实时进度展示 +│ └── ResultPage.tsx # Code/Preview 双标签页 + 可折叠聊天面板 +├── components/ +│ ├── FileTree.tsx # 递归文件树 +│ ├── CodePreview.tsx # 带行号的代码展示 +│ ├── ProgressLog.tsx # 进度日志滚动列表 +│ └── ChatPanel.tsx # 迭代修改聊天面板 +├── services/ +│ └── api.ts # API 封装 (fetch + EventSource) +└── types/ + └── index.ts # 共享类型 +``` + +### 5.2 页面流转 + +```mermaid +graph LR + A[HomePage
输入描述] -->|POST /api/generate
获得 taskId| B[ProgressPage
SSE 实时进度] + B -->|完成| C[ResultPage
Code 标签] + C -->|点击 Preview| D[ResultPage
Preview 标签
iframe 嵌入] + C -->|Download ZIP| E[下载文件] + C -->|New Project| A + C -->|聊天面板
迭代修改| F[POST /modify
SSE 进度
刷新文件] + F -->|完成| C +``` + +### 5.3 SSE 进度处理 + +前端通过 `EventSource` 连接 `/api/generate/:taskId/status`: +- 接收到 progress event 后追加到列表并自动滚动 +- 收到 `completed` 或 `error` 类型事件后关闭连接 +- 断线自动处理 + +### 5.4 迭代修改(Human-in-the-Loop) + +ResultPage 右侧集成可折叠聊天面板 (ChatPanel),支持与 AI 对话式修改已生成的项目: + +- **交互流程**: 用户输入修改指令 → POST /modify → SSE 监听进度 → 完成后刷新文件列表和代码预览 +- **对话历史**: 保留完整聊天记录(ChatMessage[]),支持多轮上下文 +- **进度展示**: 修改进行中在聊天面板内联显示进度事件 +- **状态流转**: `completed → (modify) → running → completed → (modify) → ...` +- **SSE 连接**: completed 状态不关闭连接,保持订阅等待修改事件,由前端主动关闭 + +--- + +## 6. 关键设计决策 + +| 决策 | 选择 | 原因 | +|------|------|------| +| SSE 实现方式 | `ctx.res` 直接写入 + `ctx.respond = false` | Egg.js 不原生支持 SSE,需绕过框架 response 处理 | +| 任务存储 | 内存 Map (非持久化) | MVP 阶段简单够用,重启后清空可接受。messages 数组保存在 TaskInfo 中以支持多轮修改上下文恢复 | +| 异步执行 | Controller 创建任务后立即返回,Orchestrator 后台运行 | 生成过程耗时长(分钟级),不能阻塞 HTTP 请求 | +| 预览实现 | 在 output 目录启动独立 Vite dev server | 直接复用生成项目的 Vite 配置,零额外改造 | +| Barrel export | 动态检测 `export default` | AI 生成的组件可能用命名导出或默认导出,需自适应 | +| Claude API 认证 | 支持 apiKey / authToken 双模式 | 兼容直连 Anthropic 和内部代理(Bearer Token) | +| 迭代修改方式 | 增量修改 + 对话上下文复用 | AI 基于已有 messages 历史和当前文件结构,只修改需要变动的文件,减少 token 消耗 | +| 修改模式 Prompt | 独立的 MODIFY_SYSTEM_PROMPT | 在原 SYSTEM_PROMPT 基础上追加修改规则,跳过分析/规划阶段直接修改 | + +--- + +## 7. 配置 + +### 环境变量 (`.env`) + +``` +# Option 1: Direct Anthropic API +ANTHROPIC_API_KEY=sk-ant-your-api-key-here + +# Option 2: Custom proxy endpoint +# ANTHROPIC_BASE_URL=https://your-proxy-endpoint.com/anthropic +# ANTHROPIC_AUTH_TOKEN=your-auth-token-here +``` + +### Egg.js 配置 (`config/config.default.ts`) + +```typescript +config.miniMuse = { + outputDir: './output', // 生成项目输出目录 + maxIterations: 50, // Agent 最大迭代次数 + model: 'claude-sonnet-4-20250514', + maxTokens: 8192, +}; +``` + +--- + +## 8. 目录结构 + +``` +mini-muse/ +├── app/ +│ ├── controller/ # Egg.js 控制器 +│ │ ├── generate.ts # 生成 API (CRUD + SSE + 预览) +│ │ └── home.ts # SPA fallback +│ ├── service/ # Egg.js 服务 +│ │ ├── aiClient.ts # Claude SDK 封装 +│ │ ├── orchestrator.ts # Agent 主循环 +│ │ ├── taskManager.ts # 任务管理 + EventEmitter +│ │ ├── tools.ts # 工具调用入口 +│ │ └── preview.ts # 预览服务器管理 +│ ├── middleware/ +│ │ └── errorHandler.ts # API 错误统一处理 +│ ├── lib/ # 纯业务逻辑 (框架无关) +│ │ ├── prompts.ts +│ │ ├── utils.ts +│ │ └── tools/ # 11 个 AI 工具 +│ ├── public/ # 前端构建产物 +│ └── router.ts +├── config/ # Egg.js 配置 +├── frontend/ # React SPA 源码 +│ └── src/ +├── typings/ # Egg.js 类型声明 +├── docs/ +│ └── design.md # 本文档 +├── .env # 环境变量 (不提交) +├── CLAUDE.md # Claude Code 项目规范 +├── package.json +└── tsconfig.json +``` diff --git a/mini-muse/frontend/index.html b/mini-muse/frontend/index.html new file mode 100644 index 0000000..3d7e6df --- /dev/null +++ b/mini-muse/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Mini-Muse - AI Web App Generator + + +
+ + + diff --git a/mini-muse/frontend/package.json b/mini-muse/frontend/package.json new file mode 100644 index 0000000..a76da7c --- /dev/null +++ b/mini-muse/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "mini-muse-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.0", + "vite": "^5.1.0" + } +} diff --git a/mini-muse/frontend/postcss.config.js b/mini-muse/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/mini-muse/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/mini-muse/frontend/src/App.tsx b/mini-muse/frontend/src/App.tsx new file mode 100644 index 0000000..0dea33b --- /dev/null +++ b/mini-muse/frontend/src/App.tsx @@ -0,0 +1,24 @@ +import { Routes, Route } from 'react-router-dom'; +import HomePage from './pages/HomePage'; +import ProgressPage from './pages/ProgressPage'; +import ResultPage from './pages/ResultPage'; + +export default function App() { + return ( +
+
+
+

Mini-Muse

+ AI Web App Generator +
+
+
+ + } /> + } /> + } /> + +
+
+ ); +} diff --git a/mini-muse/frontend/src/components/ChatPanel.tsx b/mini-muse/frontend/src/components/ChatPanel.tsx new file mode 100644 index 0000000..83ef37a --- /dev/null +++ b/mini-muse/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,188 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import type { ChatMessage, ProgressEvent } from '../types'; +import { modifyTask, getChatHistory, subscribeToProgress, getFiles, getFileContent } from '../services/api'; + +interface ChatPanelProps { + taskId: string; + onFilesUpdated: (files: string[]) => void; + onFileContentUpdated: (file: string, content: string) => void; + selectedFile: string; +} + +const eventTypeStyles: Record = { + status: 'text-blue-500', + thinking: 'text-gray-400', + tool_call: 'text-purple-500', + tool_result: 'text-green-500', + file_created: 'text-emerald-500', + modify_started: 'text-blue-500', + modify_completed: 'text-green-600', + error: 'text-red-500', +}; + +export default function ChatPanel({ taskId, onFilesUpdated, onFileContentUpdated, selectedFile }: ChatPanelProps) { + const [chatHistory, setChatHistory] = useState([]); + const [input, setInput] = useState(''); + const [isModifying, setIsModifying] = useState(false); + const [modifyEvents, setModifyEvents] = useState([]); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + const unsubscribeRef = useRef<(() => void) | null>(null); + const modifyStartTimeRef = useRef(0); + + // Load chat history on mount + useEffect(() => { + getChatHistory(taskId).then(setChatHistory).catch(() => {}); + }, [taskId]); + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [chatHistory, modifyEvents]); + + const handleSend = useCallback(async () => { + const instruction = input.trim(); + if (!instruction || isModifying) return; + + setInput(''); + setIsModifying(true); + setModifyEvents([]); + modifyStartTimeRef.current = Date.now(); + + // Optimistically add user message + const userMsg: ChatMessage = { role: 'user', content: instruction, timestamp: Date.now() }; + setChatHistory(prev => [...prev, userMsg]); + + try { + await modifyTask(taskId, instruction); + + // Subscribe to SSE for modification progress + unsubscribeRef.current = subscribeToProgress( + taskId, + (event) => { + const ev = event as ProgressEvent; + // Filter out events from before the modification started + if (ev.timestamp < modifyStartTimeRef.current) return; + setModifyEvents(prev => [...prev, ev]); + + if (ev.type === 'completed' || ev.type === 'modify_completed') { + // Refresh chat history, files, and selected file content + getChatHistory(taskId).then(history => { + setChatHistory(history); + setModifyEvents([]); + setIsModifying(false); + }); + + getFiles(taskId).then(onFilesUpdated); + + if (selectedFile) { + getFileContent(taskId, selectedFile).then(content => { + onFileContentUpdated(selectedFile, content); + }).catch(() => {}); + } + } + + if (ev.type === 'error') { + setIsModifying(false); + } + }, + () => { + // SSE connection closed + } + ); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + setChatHistory(prev => [...prev, { role: 'assistant', content: `Error: ${errMsg}`, timestamp: Date.now() }]); + setIsModifying(false); + } + }, [input, isModifying, taskId, onFilesUpdated, onFileContentUpdated, selectedFile]); + + // Cleanup SSE on unmount + useEffect(() => { + return () => { + unsubscribeRef.current?.(); + }; + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {/* Header */} +
+ Modify Project +
+ + {/* Messages area */} +
+ {chatHistory.length === 0 && !isModifying && ( +
+ Describe changes to modify the generated project +
+ )} + + {chatHistory.map((msg, i) => ( +
+
+ {msg.content} +
+
+ ))} + + {/* Modification progress events */} + {isModifying && modifyEvents.length > 0 && ( +
+ {modifyEvents.map((ev, i) => ( +
+ {ev.message} +
+ ))} +
+ )} + + {isModifying && ( +
+
+ Modifying... +
+ )} + +
+
+ + {/* Input area */} +
+
+