-
Notifications
You must be signed in to change notification settings - Fork 0
[WIP] feat: add mini-muse - AI web app generator example #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
71a7d0c
618d231
9b6dda7
33b8e37
e47d2f3
7b0829f
2973efa
d806686
e582d47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 等) | ||
| - 关键设计决策及原因 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.'; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AgentStore> { | ||
| 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/', | ||
| }); | ||
|
Comment on lines
+13
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate OSS config before constructing the store. This can build the OSS client with empty credentials or bucket names, so misconfiguration only shows up later on the first persistence call. Fail fast here with a clear error if the required OSS env vars are missing. 🤖 Prompt for AI Agents |
||
|
|
||
| if (store.init) await store.init(); | ||
| return store as AgentStore; | ||
| } | ||
|
|
||
| async *execRun(input: CreateRunInput, signal?: AbortSignal): AsyncGenerator<AgentStreamMessage> { | ||
| 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(''); | ||
|
Comment on lines
+33
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject empty or non-text run inputs up front. Line 34 assumes at least one message exists and that every content part has a 🤖 Prompt for AI Agents |
||
|
|
||
| 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, | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}`; | ||
|
Comment on lines
+23
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden
Use a shared Also applies to: 43-46, 62-64, 83-86 🤖 Prompt for AI Agents |
||
| 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 }, | ||
| }; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep the SPA fallback off
/api/*.This catch-all GET route also matches API URLs, so unknown
GET /api/...requests will return HTML instead of a JSON 404, and route registration order can also shadow real GET controllers. Guard/apibefore servingindex.html, or narrow the fallback to non-API paths only.Possible fix
`@HTTPController`({ path: '/' }) export class SpaFallbackController { `@HTTPMethod`({ method: HTTPMethodEnum.GET, path: '/*' }) async index(`@HTTPContext`() ctx: any) { + if (ctx.path === '/api' || ctx.path.startsWith('/api/')) { + ctx.throw(404, 'Not Found'); + } + const indexPath = path.join(process.cwd(), 'app/public/index.html'); try { const html = await fs.readFile(indexPath, 'utf-8');🤖 Prompt for AI Agents