diff --git a/mini-muse/.env.example b/mini-muse/.env.example new file mode 100644 index 0000000..58c049c --- /dev/null +++ b/mini-muse/.env.example @@ -0,0 +1,11 @@ +# Anthropic API +ANTHROPIC_API_KEY= +ANTHROPIC_BASE_URL= +ANTHROPIC_AUTH_TOKEN= + +# Alibaba Cloud OSS +OSS_REGION=oss-cn-hangzhou +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_BUCKET= +OSS_PREFIX=mini-muse/ diff --git a/mini-muse/.gitignore b/mini-muse/.gitignore new file mode 100644 index 0000000..932cb9b --- /dev/null +++ b/mini-muse/.gitignore @@ -0,0 +1,21 @@ +node_modules +coverage +*.log +npm-debug.log +.logs +logs +*.sw* +run +*-run +.idea +.DS_Store +package-lock.json +yarn.lock +.env +output +typings +app/public +.search-results +.claude/settings.local.json +.egg +frontend/node_modules 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/HomeController.ts b/mini-muse/app/controller/HomeController.ts new file mode 100644 index 0000000..554f7a5 --- /dev/null +++ b/mini-muse/app/controller/HomeController.ts @@ -0,0 +1,18 @@ +import { HTTPController, HTTPMethod, HTTPMethodEnum, HTTPContext } from '@eggjs/tegg'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +@HTTPController({ path: '/' }) +export class SpaFallbackController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/*' }) + async index(@HTTPContext() ctx: any) { + const indexPath = path.join(process.cwd(), 'app/public/index.html'); + try { + const html = await fs.readFile(indexPath, 'utf-8'); + ctx.type = 'text/html'; + return html; + } catch { + return 'Mini-Muse is running. Please build the frontend first.'; + } + } +} diff --git a/mini-muse/app/controller/MuseAgentController.ts b/mini-muse/app/controller/MuseAgentController.ts new file mode 100644 index 0000000..e141521 --- /dev/null +++ b/mini-muse/app/controller/MuseAgentController.ts @@ -0,0 +1,57 @@ +import { AgentController, Inject } from '@eggjs/tegg'; +import type { AgentHandler } from '@eggjs/controller-decorator'; +import type { AgentStore, AgentStreamMessage, CreateRunInput } from '@eggjs/tegg-types'; +import { OSSAgentStore, OSSObjectStorageClient } from '@eggjs/agent-runtime'; +import { OSSObject } from 'oss-client'; +import { OrchestratorService } from '../service/orchestrator'; + +@AgentController() +export class MuseAgentController implements AgentHandler { + @Inject() + private readonly orchestratorService!: OrchestratorService; + + async createStore(): Promise { + const endpoint = process.env.OSS_ENDPOINT || `https://${process.env.OSS_REGION || 'oss-cn-hangzhou'}.aliyuncs.com`; + + const ossClient = new OSSObject({ + endpoint, + accessKeyId: process.env.OSS_ACCESS_KEY_ID || '', + accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '', + bucket: process.env.OSS_BUCKET || '', + }); + + const storageClient = new OSSObjectStorageClient(ossClient); + const store = new OSSAgentStore({ + client: storageClient, + prefix: process.env.OSS_PREFIX || 'mini-muse/', + }); + + if (store.init) await store.init(); + return store as AgentStore; + } + + async *execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator { + const userMessage = input.input.messages[0]; + const description = typeof userMessage.content === 'string' + ? userMessage.content + : userMessage.content.map((p: { type: string; text: string }) => p.text).join(''); + + const appName = (input.metadata?.appName as string) || 'my-app'; + const threadId = input.threadId || 'unknown'; + + const maxIterations = input.config?.maxIterations || 50; + const outputDir = `./output/${threadId}`; + + const isModification = input.metadata?.type === 'modify'; + + yield* this.orchestratorService.agentLoop({ + description, + appName, + outputDir, + maxIterations, + isModification, + threadId, + signal, + }); + } +} diff --git a/mini-muse/app/controller/ProjectController.ts b/mini-muse/app/controller/ProjectController.ts new file mode 100644 index 0000000..e6b0507 --- /dev/null +++ b/mini-muse/app/controller/ProjectController.ts @@ -0,0 +1,110 @@ +import { + HTTPController, + HTTPMethod, + HTTPMethodEnum, + HTTPParam, + HTTPQuery, + Inject, + HTTPContext, +} from '@eggjs/tegg'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import archiver from 'archiver'; +import { listFiles } from '../lib/utils'; +import { PreviewService } from '../service/preview'; + +@HTTPController({ path: '/api/v1/projects' }) +export class ProjectController { + @Inject() + private readonly previewService!: PreviewService; + + // GET /api/v1/projects/:threadId/files + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/:threadId/files' }) + async files(@HTTPParam({ name: 'threadId' }) threadId: string) { + const outputDir = `./output/${threadId}`; + try { + const allFiles = await listFiles(outputDir); + const fileTree = allFiles.map(f => path.relative(outputDir, f)); + return { success: true, data: { threadId, files: fileTree } }; + } catch { + return { success: true, data: { threadId, files: [] } }; + } + } + + // GET /api/v1/projects/:threadId/file?path=xxx + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/:threadId/file' }) + async fileContent( + @HTTPParam({ name: 'threadId' }) threadId: string, + @HTTPQuery({ name: 'path' }) filePath: string, + ) { + if (!filePath) { + return { success: false, error: 'path query parameter is required' }; + } + const outputDir = `./output/${threadId}`; + const fullPath = path.resolve(outputDir, filePath); + if (!fullPath.startsWith(path.resolve(outputDir))) { + return { success: false, error: 'Access denied' }; + } + try { + const content = await fs.readFile(fullPath, 'utf-8'); + return { success: true, data: { path: filePath, content } }; + } catch { + return { success: false, error: 'File not found' }; + } + } + + // GET /api/v1/projects/:threadId/download + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/:threadId/download' }) + async download( + @HTTPParam({ name: 'threadId' }) threadId: string, + @HTTPContext() ctx: any, + ) { + const outputDir = `./output/${threadId}`; + try { + await fs.access(outputDir); + } catch { + return { success: false, error: 'Project not found' }; + } + + ctx.res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="project-${threadId.slice(0, 8)}.zip"`, + }); + ctx.respond = false; + + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.pipe(ctx.res); + archive.directory(outputDir, `project-${threadId.slice(0, 8)}`); + await archive.finalize(); + } + + // POST /api/v1/projects/:threadId/preview + @HTTPMethod({ method: HTTPMethodEnum.POST, path: '/:threadId/preview' }) + async startPreview(@HTTPParam({ name: 'threadId' }) threadId: string) { + const outputDir = `./output/${threadId}`; + try { + await fs.access(outputDir); + } catch { + return { success: false, error: 'Project not found' }; + } + + const info = await this.previewService.start(threadId, outputDir); + return { + success: true, + data: { status: info.status, port: info.port, error: info.error }, + }; + } + + // GET /api/v1/projects/:threadId/preview + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/:threadId/preview' }) + async previewStatus(@HTTPParam({ name: 'threadId' }) threadId: string) { + const info = this.previewService.getStatus(threadId); + if (!info) { + return { success: false, error: 'No preview found for this project' }; + } + return { + success: true, + data: { status: info.status, port: info.port, error: info.error }, + }; + } +} 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..220a01d --- /dev/null +++ b/mini-muse/app/lib/tools/analyzer/analyzeRequirements.ts @@ -0,0 +1,65 @@ +import { ToolResult } from '../registry'; + +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..3485cc7 --- /dev/null +++ b/mini-muse/app/lib/tools/analyzer/planArchitecture.ts @@ -0,0 +1,70 @@ +import { ToolResult } from '../registry'; + +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..ea5b000 --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createComponent.ts @@ -0,0 +1,73 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode, toPascalCase } from '../../utils'; + +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..c9a1af8 --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createConfig.ts @@ -0,0 +1,177 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode } from '../../utils'; + +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..b6cbbc9 --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createFile.ts @@ -0,0 +1,58 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode } from '../../utils'; + +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..6179a5a --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createHook.ts @@ -0,0 +1,55 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode, toCamelCase } from '../../utils'; + +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..dec9b35 --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createPage.ts @@ -0,0 +1,65 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode, toPascalCase } from '../../utils'; + +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..71c59ec --- /dev/null +++ b/mini-muse/app/lib/tools/generator/createStyle.ts @@ -0,0 +1,44 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { writeFile, formatCode } from '../../utils'; + +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..3c18941 --- /dev/null +++ b/mini-muse/app/lib/tools/generator/deleteFile.ts @@ -0,0 +1,44 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { ToolResult } from '../registry'; + +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..9d25a2c --- /dev/null +++ b/mini-muse/app/lib/tools/reader/readFile.ts @@ -0,0 +1,45 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { ToolResult } from '../registry'; + +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..7fc51d1 --- /dev/null +++ b/mini-muse/app/lib/tools/registry.ts @@ -0,0 +1,64 @@ +import { analyzeRequirements } from './analyzer/analyzeRequirements'; +import { planArchitecture } from './analyzer/planArchitecture'; +import { createConfig } from './generator/createConfig'; +import { createFile } from './generator/createFile'; +import { createComponent } from './generator/createComponent'; +import { createPage } from './generator/createPage'; +import { createHook } from './generator/createHook'; +import { createStyle } from './generator/createStyle'; +import { validateProject } from './validator/validateProject'; +import { readProjectFile } from './reader/readFile'; +import { deleteFile } from './generator/deleteFile'; + +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, +}; + +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..56e9e91 --- /dev/null +++ b/mini-muse/app/lib/tools/validator/validateProject.ts @@ -0,0 +1,220 @@ +import * as path from 'path'; +import { ToolResult } from '../registry'; +import { fileExists, listFiles, readFile } from '../../utils'; + +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/service/orchestrator.ts b/mini-muse/app/service/orchestrator.ts new file mode 100644 index 0000000..b62b408 --- /dev/null +++ b/mini-muse/app/service/orchestrator.ts @@ -0,0 +1,119 @@ +import { SingletonProto, AccessLevel, Inject, LifecyclePostInject } from '@eggjs/tegg'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { SYSTEM_PROMPT, MODIFY_SYSTEM_PROMPT, createUserPrompt, createModifyPrompt } from '../lib/prompts'; +import { listFiles } from '../lib/utils'; +import { ToolsService } from './tools'; +import type { AgentStreamMessage } from '@eggjs/tegg-types'; + +export interface AgentLoopParams { + description: string; + appName: string; + outputDir: string; + maxIterations: number; + isModification: boolean; + threadId: string; + signal?: AbortSignal; +} + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class OrchestratorService { + @Inject() + private readonly toolsService!: ToolsService; + + @LifecyclePostInject() + protected init() { + // Agent SDK (Claude CLI) reads ANTHROPIC_MODEL, not CLAUDE_MODEL + if (process.env.CLAUDE_MODEL && !process.env.ANTHROPIC_MODEL) { + process.env.ANTHROPIC_MODEL = process.env.CLAUDE_MODEL; + } + // Disable telemetry / auto-update / marketplace to avoid network calls + process.env.CLAUDE_CODE_ENABLE_TELEMETRY = '0'; + process.env.CLAUDE_CODE_DISABLE_AUTO_UPDATE = '1'; + process.env.CLAUDE_CODE_DISABLE_MARKETPLACE = '1'; + process.env.CLAUDE_CODE_OFFLINE_MODE = '1'; + + // Ensure ~/.claude.json exists with onboarding completed + const claudeConfigPath = path.join(os.homedir(), '.claude.json'); + try { + fs.accessSync(claudeConfigPath); + } catch { + fs.writeFileSync(claudeConfigPath, JSON.stringify({ + numStartups: 10, + autoUpdaterStatus: 'disabled', + hasCompletedOnboarding: true, + lastOnboardingVersion: '0.2.45', + telemetryEnabled: false, + analyticsEnabled: false, + }, null, 2)); + } + } + + async *agentLoop(params: AgentLoopParams): AsyncGenerator { + const { description, outputDir, maxIterations, isModification, signal } = params; + + let systemPrompt: string; + let prompt: string; + + if (isModification) { + systemPrompt = MODIFY_SYSTEM_PROMPT; + let relativeFiles: string[] = []; + try { + const fileList = await listFiles(outputDir); + relativeFiles = fileList.map(f => path.relative(outputDir, f)); + } catch { /* directory might not exist */ } + prompt = createModifyPrompt(description, relativeFiles); + } else { + systemPrompt = SYSTEM_PROMPT; + prompt = createUserPrompt(description); + } + + yield { message: { content: `[status] Starting ${isModification ? 'modification' : 'generation'}...` } }; + + // Ensure outputDir exists before Agent SDK spawns process with it as cwd + fs.mkdirSync(outputDir, { recursive: true }); + + try { + const messageStream = query({ + prompt, + options: { + model: process.env.ANTHROPIC_MODEL, + cwd: outputDir, + systemPrompt, + maxTurns: maxIterations, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + mcpServers: this.toolsService.getMcpServers(outputDir), + allowedTools: this.toolsService.getAllowedTools(), + stderr: (msg: string) => { + console.log('[agent-sdk stderr]', msg); + }, + }, + }); + + for await (const message of messageStream) { + if (signal?.aborted) return; + + if ('result' in message) { + yield { message: { content: `[completed] ${isModification ? 'Modification' : 'Generation'} complete!` } }; + yield { message: { content: `[result] ${message.result}` } }; + } else if (message.type === 'assistant') { + for (const block of message.message.content) { + if (block.type === 'text') { + yield { message: { content: `[thinking] ${block.text.slice(0, 500)}${block.text.length > 500 ? '...' : ''}` } }; + } else if (block.type === 'tool_use') { + yield { message: { content: `[tool_call] Executing: ${block.name}` } }; + } + } + } else if (message.type === 'system' && message.subtype === 'init') { + yield { message: { content: '[status] Agent session initialized' } }; + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + yield { message: { content: `[error] ${errorMessage}` } }; + } + } +} diff --git a/mini-muse/app/service/package.json b/mini-muse/app/service/package.json new file mode 100644 index 0000000..7e8d398 --- /dev/null +++ b/mini-muse/app/service/package.json @@ -0,0 +1,7 @@ +{ + "name": "mini-muse-service", + "type": "module", + "eggModule": { + "name": "miniMuse" + } +} diff --git a/mini-muse/app/service/preview.ts b/mini-muse/app/service/preview.ts new file mode 100644 index 0000000..ba25650 --- /dev/null +++ b/mini-muse/app/service/preview.ts @@ -0,0 +1,136 @@ +import { SingletonProto, AccessLevel } from '@eggjs/tegg'; +import { spawn, ChildProcess } from 'child_process'; +import * as net from 'net'; + +interface PreviewInfo { + threadId: 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')); + } + }); + }); +} + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class PreviewService { + async start(threadId: string, outputDir: string): Promise { + const existing = previews.get(threadId); + if (existing && ['running', 'installing', 'starting'].includes(existing.status)) { + return existing; + } + + const info: PreviewInfo = { + threadId, + status: 'installing', + }; + previews.set(threadId, info); + + this.installAndStart(threadId, outputDir, info).catch(err => { + console.error('Preview error for thread %s: %s', threadId, err.message); + }); + + return info; + } + + getStatus(threadId: string): PreviewInfo | undefined { + const info = previews.get(threadId); + if (!info) return undefined; + return { + threadId: info.threadId, + status: info.status, + port: info.port, + error: info.error, + }; + } + + stop(threadId: string): void { + const info = previews.get(threadId); + if (info?.process) { + info.process.kill('SIGTERM'); + info.status = 'failed'; + previews.delete(threadId); + } + } + + private async installAndStart(threadId: 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/tools.ts b/mini-muse/app/service/tools.ts new file mode 100644 index 0000000..1094a0d --- /dev/null +++ b/mini-muse/app/service/tools.ts @@ -0,0 +1,206 @@ +import { SingletonProto, AccessLevel } from '@eggjs/tegg'; +import { tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { executeTool, ToolName } from '../lib/tools/registry'; + +const MCP_SERVER_NAME = 'mini-muse-tools'; + +interface ToolMeta { + name: ToolName; + description: string; + schema: Parameters[2]; +} + +// Tool definitions — schema + metadata only, no runtime state +const TOOL_DEFS: ToolMeta[] = [ + { + 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.', + schema: { + description: z.string().describe('The original user description of the application'), + appName: z.string().describe('A kebab-case name for the application (e.g., "todo-app")'), + appTitle: z.string().describe('Human-readable title for the application (e.g., "Todo App")'), + pages: z.array(z.object({ + name: z.string().describe('PascalCase page name'), + path: z.string().describe('URL path for the page'), + description: z.string().describe('What this page does'), + })).describe('List of pages/routes in the application'), + components: z.array(z.object({ + name: z.string().describe('PascalCase component name'), + description: z.string().describe('What this component does'), + props: z.array(z.object({ + name: z.string(), + type: z.string(), + required: z.boolean().optional(), + })).optional(), + })).describe('List of reusable components needed'), + dataModels: z.array(z.object({ + name: z.string().describe('PascalCase type name'), + description: z.string().optional().describe('What this type represents'), + fields: z.array(z.object({ + name: z.string(), + type: z.string(), + optional: z.boolean().optional(), + })), + })).describe('TypeScript interfaces/types for the data model'), + features: z.array(z.string()).describe('List of features the application should have'), + hooks: z.array(z.object({ + name: z.string().describe('Hook name starting with "use"'), + description: z.string().describe('What this hook does'), + })).optional().describe('Custom hooks needed for the application'), + }, + }, + { + name: 'plan_architecture', + description: 'Plan the application architecture based on analyzed requirements. Creates a detailed file structure and component hierarchy.', + schema: { + appName: z.string().describe('The application name from requirements analysis'), + structure: z.object({ + src: z.array(z.string()).describe('Files directly in src/ directory'), + components: z.array(z.string()).describe('Component directories to create'), + pages: z.array(z.string()).describe('Page directories to create'), + hooks: z.array(z.string()).optional().describe('Hook files to create'), + types: z.array(z.string()).optional().describe('Type definition files to create'), + utils: z.array(z.string()).optional().describe('Utility files to create'), + styles: z.array(z.string()).optional().describe('Style files to create'), + }), + fileCreationOrder: z.array(z.object({ + path: z.string().describe('File path relative to project root'), + type: z.enum(['config', 'type', 'util', 'hook', 'component', 'page', 'style', 'entry']).describe('Type of file'), + description: z.string().describe('What this file contains'), + })).describe('Ordered list of files to create (dependencies first)'), + componentHierarchy: z.array(z.object({ + component: z.string(), + usedIn: z.array(z.string()).optional(), + uses: z.array(z.string()).optional(), + })).optional().describe('Component dependency graph'), + routingConfig: z.object({ + hasRouter: z.boolean(), + routes: z.array(z.object({ + path: z.string(), + component: z.string(), + isIndex: z.boolean().optional(), + })).optional(), + }).optional().describe('Routing configuration if multiple pages'), + }, + }, + { + name: 'create_config', + description: 'Create project configuration files like package.json, tsconfig.json, vite.config.ts, and index.html.', + schema: { + configType: z.enum(['package.json', 'tsconfig.json', 'vite.config.ts', 'index.html']).describe('Type of configuration file to create'), + appName: z.string().optional().describe('Application name for package.json'), + appTitle: z.string().optional().describe('Application title for index.html'), + dependencies: z.record(z.string()).optional().describe('Additional dependencies to include in package.json'), + hasRouter: z.boolean().optional().describe('Whether to include react-router-dom dependency'), + }, + }, + { + 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.', + schema: { + filePath: z.string().describe('Path relative to src/ directory (e.g., "types/index.ts", "utils/helpers.ts")'), + content: z.string().describe('The complete file content'), + fileType: z.enum(['typescript', 'css', 'json']).optional().describe('File type for formatting'), + }, + }, + { + name: 'create_component', + description: 'Create a React component with its associated CSS module. The component will be created as a functional component with TypeScript.', + schema: { + name: z.string().describe('Component name in PascalCase (e.g., "TodoItem", "Header")'), + description: z.string().optional().describe('Brief description of what the component does'), + props: z.array(z.object({ + name: z.string().describe('Prop name'), + type: z.string().describe('TypeScript type'), + required: z.boolean().optional().describe('Whether the prop is required'), + defaultValue: z.string().optional().describe('Default value if optional'), + description: z.string().optional().describe('Prop description'), + })).optional().describe('Component props'), + componentCode: z.string().describe('The complete React component code (TSX)'), + styleCode: z.string().optional().describe('CSS module styles for the component'), + directory: z.string().optional().describe('Subdirectory within components/ (optional, e.g., "common", "layout")'), + }, + }, + { + name: 'create_page', + description: 'Create a page component. Pages are top-level components that represent routes in the application.', + schema: { + name: z.string().describe('Page name in PascalCase (e.g., "HomePage", "SettingsPage")'), + routePath: z.string().describe('URL path for the page (e.g., "/", "/settings", "/users/:id")'), + description: z.string().optional().describe('Brief description of the page'), + pageCode: z.string().describe('The complete React page component code (TSX)'), + styleCode: z.string().optional().describe('CSS module styles for the page'), + }, + }, + { + name: 'create_hook', + description: 'Create a custom React hook for reusable stateful logic.', + schema: { + name: z.string().describe('Hook name starting with "use" (e.g., "useTodos", "useLocalStorage")'), + description: z.string().optional().describe('Brief description of what the hook does'), + hookCode: z.string().describe('The complete hook code (TypeScript)'), + parameters: z.array(z.object({ + name: z.string(), + type: z.string(), + description: z.string().optional(), + })).optional().describe('Hook parameters'), + returnType: z.string().optional().describe('TypeScript return type of the hook'), + }, + }, + { + name: 'create_style', + description: 'Create global styles, CSS variables, or theme files.', + schema: { + fileName: z.string().describe('Style file name (e.g., "global.css", "variables.css", "theme.css")'), + styleCode: z.string().describe('The complete CSS code'), + directory: z.string().optional().describe('Directory within src/ (default: "styles")'), + }, + }, + { + name: 'validate_project', + description: 'Validate the generated project for completeness and correctness. Checks that all required files exist and imports resolve.', + schema: { + checks: z.array(z.enum(['files', 'imports', 'types', 'structure'])).optional().describe('Types of validation to perform'), + }, + }, + { + name: 'read_file', + description: 'Read the content of an existing file in the project. Use this to understand current code before making modifications.', + schema: { + filePath: z.string().describe('Path relative to the project root (e.g., "src/App.tsx", "package.json")'), + }, + }, + { + name: 'delete_file', + description: 'Delete a file from the project. Use this to remove files that are no longer needed.', + schema: { + filePath: z.string().describe('Path relative to the project root (e.g., "src/components/OldComponent.tsx")'), + }, + }, +]; + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class ToolsService { + /** + * Create MCP servers with tools bound to the given outputDir. + * Each call creates fresh tool instances, making this safe for concurrent requests. + */ + getMcpServers(outputDir: string) { + const tools = TOOL_DEFS.map(def => + tool(def.name, def.description, def.schema, async (args) => { + const result = await executeTool(def.name, args as Record, outputDir); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + }), + ); + return { + [MCP_SERVER_NAME]: createSdkMcpServer({ name: MCP_SERVER_NAME, tools }), + }; + } + + getAllowedTools() { + return TOOL_DEFS.map(def => `mcp__${MCP_SERVER_NAME}__${def.name}`); + } +} diff --git a/mini-muse/config/config.default.ts b/mini-muse/config/config.default.ts new file mode 100644 index 0000000..689453d --- /dev/null +++ b/mini-muse/config/config.default.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'egg'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default defineConfig({ + keys: 'mini-muse-secret-key', + + miniMuse: { + outputDir: './output', + maxIterations: 50, + model: 'claude-sonnet-4-20250514', + maxTokens: 8192, + }, + + security: { + csrf: { + enable: false, + }, + }, + + static: { + prefix: '/', + }, + + bodyParser: { + jsonLimit: '1mb', + }, + + oss: { + region: process.env.OSS_REGION || 'oss-cn-hangzhou', + accessKeyId: process.env.OSS_ACCESS_KEY_ID || '', + accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '', + bucket: process.env.OSS_BUCKET || '', + prefix: process.env.OSS_PREFIX || 'mini-muse/', + }, +}); diff --git a/mini-muse/config/config.local.ts b/mini-muse/config/config.local.ts new file mode 100644 index 0000000..303af42 --- /dev/null +++ b/mini-muse/config/config.local.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'egg'; + +export default defineConfig({}); diff --git a/mini-muse/config/plugin.ts b/mini-muse/config/plugin.ts new file mode 100644 index 0000000..5af156a --- /dev/null +++ b/mini-muse/config/plugin.ts @@ -0,0 +1,14 @@ +export default { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + }, + tegg: { + enable: true, + package: '@eggjs/tegg-plugin', + }, + teggController: { + enable: true, + package: '@eggjs/controller-plugin', + }, +}; diff --git a/mini-muse/docs/design.md b/mini-muse/docs/design.md new file mode 100644 index 0000000..4039d6d --- /dev/null +++ b/mini-muse/docs/design.md @@ -0,0 +1,400 @@ +# Mini-Muse 设计文档 + +## 1. 项目概述 + +Mini-Muse 是一个 AI 驱动的 Web 应用生成器。用户通过 Web 界面描述想要的应用,AI(Claude)自动生成完整的 React + TypeScript 项目代码,支持实时进度查看、代码浏览、在线预览和 ZIP 下载。 + +### 核心功能 + +- 输入自然语言描述,AI 自动生成完整 React + TypeScript 项目 +- SSE 实时推送生成进度(基于标准 Agent API 协议) +- 生成结果的文件树浏览与代码预览 +- 一键启动生成项目的本地预览(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 + tegg v4)"] + subgraph Controllers["Controllers (装饰器路由)"] + MAC["MuseAgentController
@AgentController()
/api/v1/threads, /api/v1/runs"] + PC["ProjectController
@HTTPController
/api/v1/projects"] + HC["HomeController
@HTTPController
SPA fallback"] + end + + subgraph Services["Services (@SingletonProto)"] + ORC[OrchestratorService
Agent 主循环
claude-agent-sdk query] + TS[ToolsService
MCP Server + 工具定义
getMcpServers / getAllowedTools] + PV[PreviewService
预览服务器管理] + end + + subgraph Runtime["Agent Runtime"] + AR[AgentRuntime
Thread/Run 生命周期] + SSE[HttpSSEWriter
SSE 传输] + STORE[OSSAgentStore
OSS 持久化] + end + + subgraph Lib["app/lib (纯业务逻辑)"] + PR[prompts.ts] + UT[utils.ts] + subgraph Tools["tools/"] + REG[registry.ts
工具执行注册表] + AN[analyzer/
需求分析 + 架构规划] + GN[generator/
代码生成 x6] + VL[validator/
项目验证] + end + end + end + + subgraph External + Claude[Claude API] + OSS[阿里云 OSS
Thread/Run 持久化] + FS[文件系统
output/] + end + + HP -->|POST /api/v1/runs/stream
SSE 流| MAC + PP -->|读取 SSE 流| MAC + RP -->|GET files/file/download
POST preview| PC + + MAC --> AR + AR --> ORC + AR --> SSE + AR --> STORE + STORE --> OSS + + ORC -->|@Inject| TS + TS -->|getMcpServers()| REG + REG --> Tools + Tools --> UT + ORC --> PR + Tools --> FS + PV --> FS +``` + +--- + +## 3. 技术栈 + +| 层级 | 技术 | +|------|------| +| 后端框架 | Egg.js 4 + tegg v4 (TypeScript, 装饰器风格) | +| Agent Runtime | @eggjs/agent-runtime (Thread/Run 模型, SSE) | +| 持久化存储 | OSSAgentStore (阿里云 OSS) | +| AI Agent SDK | @anthropic-ai/claude-agent-sdk | +| 前端框架 | React 18 + React Router v6 | +| 构建工具 | Vite 5 | +| 样式方案 | Tailwind CSS 3 | +| 代码格式化 | Prettier | +| 数据验证 | Zod | +| 压缩下载 | archiver | + +--- + +## 4. 后端模块详解 + +### 4.1 控制器层(装饰器路由,无需 router.ts) + +#### MuseAgentController (`app/controller/MuseAgentController.ts`) + +使用 `@AgentController()` 装饰器,自动注册标准 Agent API 路由: + +| 方法 | 路由 | 说明 | +|------|------|------| +| POST | `/api/v1/threads` | 创建 Thread(会话) | +| GET | `/api/v1/threads/:id` | 获取 Thread(含消息历史) | +| POST | `/api/v1/runs` | 异步执行 Run | +| POST | `/api/v1/runs/stream` | 流式执行 Run(SSE) | +| POST | `/api/v1/runs/wait` | 同步等待 Run | +| GET | `/api/v1/runs/:id` | 获取 Run 状态 | +| POST | `/api/v1/runs/:id/cancel` | 取消 Run | + +实现 `AgentHandler` 接口: +- **`createStore()`**: 创建 `OSSAgentStore`,通过 `OSSObjectStorageClient` 连接阿里云 OSS +- **`execRun(input, signal)`**: async generator,从 `CreateRunInput` 提取参数,委托给 `OrchestratorService.agentLoop()` + +#### ProjectController (`app/controller/ProjectController.ts`) + +使用 `@HTTPController({ path: '/api/v1/projects' })` 装饰器: + +| 方法 | 路由 | 说明 | +|------|------|------| +| GET | `/:threadId/files` | 获取项目文件列表 | +| GET | `/:threadId/file?path=xxx` | 获取文件内容(含路径遍历防护) | +| GET | `/:threadId/download` | ZIP 下载 | +| POST | `/:threadId/preview` | 启动预览服务器 | +| GET | `/:threadId/preview` | 查询预览状态 | + +#### HomeController (`app/controller/HomeController.ts`) + +使用 `@HTTPController({ path: '/' })` 装饰器,SPA fallback 路由 `GET /*`。 + +### 4.2 Service 层(@SingletonProto) + +#### OrchestratorService (`app/service/orchestrator.ts`) + +Agent 主循环,使用 `@anthropic-ai/claude-agent-sdk` 的 `query()` 函数驱动 Agent 循环: + +- **`agentLoop(params)`**: 返回 `AsyncGenerator` + - 使用 `@Inject()` 注入 `ToolsService`,通过 `toolsService.getMcpServers()` 和 `toolsService.getAllowedTools()` 获取工具配置 + - 支持初始生成(`SYSTEM_PROMPT`)和迭代修改(`MODIFY_SYSTEM_PROMPT`)两种模式 + - 将 MCP Server 和 allowedTools 注入 `query()` 选项,Agent SDK 自动处理 tool calling loop + - 消费 `query()` 返回的 async iterator,将 `SDKAssistantMessage` / `SDKResultMessage` 转换为 `[type] message` 格式 yield 给 AgentRuntime + - 使用 `@LifecyclePostInject()` 一次性初始化 Claude 环境变量(ANTHROPIC_MODEL、离线模式等) + +```mermaid +sequenceDiagram + participant C as AgentRuntime + participant O as OrchestratorService + participant TS as ToolsService + participant SDK as claude-agent-sdk
query() + participant MCP as MCP Server
(mini-muse-tools) + participant T as Tool Executors + + C->>O: execRun(input, signal) → agentLoop() + O-->>C: yield [status] Starting... + O->>TS: setOutputDir(outputDir) + O->>TS: getMcpServers() / getAllowedTools() + O->>SDK: query({ prompt, options: { mcpServers, allowedTools, systemPrompt, maxTurns } }) + + loop Agent SDK 自动管理 agentic loop + SDK->>MCP: tool call (e.g. create_file) + MCP->>T: executeTool(name, input, outputDir) + T-->>MCP: ToolResult + MCP-->>SDK: CallToolResult + SDK-->>O: SDKAssistantMessage (tool_use / text) + O-->>C: yield [tool_call] / [thinking] + end + + SDK-->>O: SDKResultMessage + O-->>C: yield [completed] + [result] + + Note over C: AgentRuntime 将 yield 转为 SSE 事件 +``` + +#### ToolsService (`app/service/tools.ts`) + +`@SingletonProto` 服务,集中管理所有 11 个 AI 工具的定义和 MCP Server 注册(参考 wohuguiagent 的 Tools 模式): + +- 每个工具作为类属性,使用 `tool()` from Agent SDK 定义(Zod raw shape 参数 schema) +- **`setOutputDir(dir)`**: 绑定当前请求的输出目录 +- **`getTools()`**: 返回所有 tool 实例列表 +- **`getMcpServers()`**: 返回 `{ 'mini-muse-tools': createSdkMcpServer(...) }` 供 `query()` 使用 +- **`getAllowedTools()`**: 返回 `mcp__mini-muse-tools__` 格式的 allowedTools 列表 +- 工具执行委托给 `app/lib/tools/registry.ts` 的 `executeTool()` + +#### PreviewService (`app/service/preview.ts`) + +- 管理生成项目的 Vite dev server 生命周期 +- 流程: npm install → 分配空闲端口 → 启动 vite dev server +- 通过 `Map` 跟踪预览状态 +- 接收 `threadId` 和 `outputDir` 参数,不依赖 TaskManager + +### 4.3 纯业务逻辑 (`app/lib/`) + +不依赖 Egg.js/tegg 框架: + +| 文件 | 说明 | +|------|------| +| `prompts.ts` | Claude 系统提示词 (SYSTEM_PROMPT + MODIFY_SYSTEM_PROMPT) + 用户提示词模板 | +| `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 # 输入表单 + 历史会话列表 (localStorage) +│ ├── ProgressPage.tsx # SSE 实时进度展示 + 会话记录持久化 +│ └── ResultPage.tsx # Code/Preview 双标签页 + 可折叠聊天面板 +├── components/ +│ ├── FileTree.tsx # 递归文件树 +│ ├── CodePreview.tsx # 带行号的代码展示 +│ ├── ProgressLog.tsx # 进度日志滚动列表 +│ └── ChatPanel.tsx # 迭代修改聊天面板 +├── services/ +│ ├── api.ts # API 封装 (fetch + ReadableStream SSE 解析) +│ └── sessionHistory.ts # 历史会话管理 (localStorage CRUD) +└── types/ + └── index.ts # 共享类型 (RunObject, ThreadObject, ProgressEvent 等) +``` + +### 5.2 页面流转 + +```mermaid +graph LR + A[HomePage
输入描述 + 历史列表] -->|navigate with state
description + appName| B[ProgressPage
POST /runs/stream SSE] + B -->|完成
获得 threadId
保存 localStorage| C[ResultPage
Code 标签] + A -->|点击历史会话| C + C -->|点击 Preview| D[ResultPage
Preview 标签
iframe 嵌入] + C -->|Download ZIP| E[下载文件] + C -->|New Project| A + C -->|聊天面板
POST /runs/stream
with threadId| F[SSE 修改进度
刷新文件] + F -->|完成| C +``` + +### 5.3 SSE 进度处理 + +前端通过 `fetch()` + `ReadableStream` 解析 POST `/api/v1/runs/stream` 返回的 SSE 流: + +**SSE 事件格式(标准 Agent 协议):** +``` +event: thread.run.created +data: {"id":"run_xxx","threadId":"thread_xxx","status":"queued",...} + +event: thread.message.delta +data: {"id":"msg_xxx","delta":{"content":[{"type":"text","text":{"value":"[status] Processing...","annotations":[]}}]}} + +event: thread.run.completed +data: {"id":"run_xxx","status":"completed",...} + +event: done +data: "[DONE]" +``` + +**进度信息编码:** 后端 `execRun()` yield 的消息以 `[type]` 前缀编码进度类型,前端解析后映射为 ProgressEvent: +- `[status]` → 状态更新 +- `[thinking]` → AI 思考内容 +- `[tool_call]` → 工具调用 +- `[file_created]` → 文件创建 +- `[tool_result]` → 工具执行结果 +- `[completed]` → 生成完成 +- `[error]` → 错误 + +### 5.4 迭代修改(Human-in-the-Loop) + +ResultPage 右侧集成可折叠聊天面板 (ChatPanel),支持与 AI 对话式修改: + +- **交互流程**: 用户输入修改指令 → POST `/api/v1/runs/stream`(带 threadId)→ 读取 SSE 流 → 完成后刷新文件列表和代码预览 +- **对话历史**: 通过 `GET /api/v1/threads/:id` 获取 Thread 消息历史 +- **进度展示**: 修改进行中在聊天面板内联显示进度事件 + +--- + +## 6. 核心概念映射 + +| 概念 | 说明 | +|------|------| +| **Thread** | 一个项目会话,包含所有消息历史,threadId 用于标识项目 | +| **Run** | 在 Thread 上的一次执行(初始生成或迭代修改) | +| **AgentStreamMessage** | execRun 的 yield 类型,包含进度文本和 token 用量 | +| **outputDir** | `./output/{threadId}`,生成项目的文件存储位置 | + +--- + +## 7. 关键设计决策 + +| 决策 | 选择 | 原因 | +|------|------|------| +| Agent 协议 | @AgentController (标准 Thread/Run 模型) | 标准化 API,自动处理 SSE/Thread/Run 生命周期 | +| 持久化存储 | OSSAgentStore (阿里云 OSS) | Thread/Run 数据持久化,支持应用重启后恢复 | +| SSE 实现 | AgentRuntime + HttpSSEWriter | 标准 Agent SSE 事件格式,无需手动管理 SSE 连接 | +| 前端 SSE 消费 | fetch + ReadableStream | POST 请求的流式响应(EventSource 仅支持 GET) | +| Service 层 | @SingletonProto + @Inject | tegg DI,解耦服务间依赖 | +| 进度编码 | `[type] message` 文本前缀 | 在标准 Agent 协议文本消息内编码自定义进度类型 | +| 预览实现 | 在 output 目录启动独立 Vite dev server | 直接复用生成项目的 Vite 配置 | +| Agent SDK | @anthropic-ai/claude-agent-sdk + ToolsService (DI) | SDK 自动管理 agentic loop,ToolsService 集中管理工具定义和 MCP Server | +| 迭代修改 | 同一 Thread 上创建新 Run | 共享会话上下文,AI 可读取已生成文件进行增量修改 | +| 历史会话 | localStorage (前端) | 后端无 list threads API,用 localStorage 记录 threadId + 元信息,零后端改动 | + +--- + +## 8. 配置 + +### 环境变量 (`.env`) + +```bash +# Anthropic API (由 claude-agent-sdk 内部使用) +ANTHROPIC_API_KEY=sk-ant-your-api-key-here + +# Alibaba Cloud OSS +OSS_REGION=oss-cn-hangzhou +OSS_ENDPOINT= # 可选:自定义 endpoint +OSS_ACCESS_KEY_ID=your-ak +OSS_ACCESS_KEY_SECRET=your-sk +OSS_BUCKET=your-bucket +OSS_PREFIX=mini-muse/ +``` + +### Egg.js 配置 (`config/config.default.ts`) + +```typescript +config.miniMuse = { + outputDir: './output', + maxIterations: 50, + model: 'claude-sonnet-4-20250514', + maxTokens: 8192, +}; + +config.oss = { + region: process.env.OSS_REGION || 'oss-cn-hangzhou', + accessKeyId: process.env.OSS_ACCESS_KEY_ID || '', + accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '', + bucket: process.env.OSS_BUCKET || '', + prefix: process.env.OSS_PREFIX || 'mini-muse/', +}; +``` + +### tegg 插件 (`config/plugin.ts`) + +```typescript +teggConfig: { enable: true, package: '@eggjs/tegg-config' } +tegg: { enable: true, package: '@eggjs/tegg-plugin' } +teggController: { enable: true, package: '@eggjs/controller-plugin' } +``` + +--- + +## 9. 目录结构 + +``` +mini-muse/ +├── app/ +│ ├── controller/ # tegg 装饰器控制器 +│ │ ├── MuseAgentController.ts # @AgentController - 标准 Agent API +│ │ ├── ProjectController.ts # @HTTPController - 文件/预览 API +│ │ └── HomeController.ts # @HTTPController - SPA fallback +│ ├── service/ # tegg @SingletonProto 服务 +│ │ ├── orchestrator.ts # Agent 主循环 (claude-agent-sdk query + @Inject ToolsService) +│ │ ├── tools.ts # 工具定义 + MCP Server (getMcpServers/getAllowedTools) +│ │ └── preview.ts # 预览服务器管理 +│ ├── middleware/ +│ │ └── errorHandler.ts # API 错误统一处理 +│ ├── lib/ # 纯业务逻辑 (框架无关) +│ │ ├── prompts.ts +│ │ ├── utils.ts +│ │ └── tools/ # 11 个 AI 工具 +│ ├── public/ # 前端构建产物 +│ └── module.json # tegg 模块声明 +├── config/ # Egg.js + tegg 配置 +│ ├── config.default.ts +│ ├── config.local.ts +│ └── plugin.ts +├── frontend/ # React SPA 源码 +│ └── src/ +├── docs/ +│ └── design.md # 本文档 +├── .env # 环境变量 (不提交) +├── .env.example # 环境变量模板 +├── 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..afe62cc --- /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..643fc20 --- /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 { startRun, getChatHistory, getFiles, getFileContent } from '../services/api'; + +interface ChatPanelProps { + threadId: 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({ threadId, 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 cancelRef = useRef<(() => void) | null>(null); + + // Load chat history on mount + useEffect(() => { + getChatHistory(threadId).then(history => { + setChatHistory(history as ChatMessage[]); + }).catch(() => {}); + }, [threadId]); + + // 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([]); + + // Optimistically add user message + const userMsg: ChatMessage = { role: 'user', content: instruction, timestamp: Date.now() }; + setChatHistory(prev => [...prev, userMsg]); + + try { + const cancel = await startRun( + instruction, + 'modification', + (event) => { + setModifyEvents(prev => [...prev, event]); + + if (event.type === 'completed' || event.type === 'modify_completed') { + // Refresh chat history, files, and selected file content + getChatHistory(threadId).then(history => { + setChatHistory(history as ChatMessage[]); + setModifyEvents([]); + setIsModifying(false); + }); + + getFiles(threadId).then(onFilesUpdated); + + if (selectedFile) { + getFileContent(threadId, selectedFile).then(content => { + onFileContentUpdated(selectedFile, content); + }).catch(() => {}); + } + } + + if (event.type === 'error') { + setIsModifying(false); + } + }, + () => { + // onThreadId - not needed for modify, threadId already known + }, + () => { + // onDone + }, + threadId + ); + cancelRef.current = cancel; + } 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, threadId, onFilesUpdated, onFileContentUpdated, selectedFile]); + + // Cleanup on unmount + useEffect(() => { + return () => { + cancelRef.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 */} +
+
+