Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions mini-muse/.env.example
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/
21 changes: 21 additions & 0 deletions mini-muse/.gitignore
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
20 changes: 20 additions & 0 deletions mini-muse/CLAUDE.md
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 等)
- 关键设计决策及原因
62 changes: 62 additions & 0 deletions mini-muse/README.md
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.
18 changes: 18 additions & 0 deletions mini-muse/app/controller/HomeController.ts
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.';
Comment on lines +5 to +15
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 /api before serving index.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
Verify each finding against the current code and only fix it if needed.

In `@mini-muse/app/controller/HomeController.ts` around lines 5 - 15, The SPA
fallback currently matches all GET routes (HTTPMethod path '/*') and lives in
SpaFallbackController.index; add a guard at the start of the index method to
skip serving index.html for API routes: check ctx.request.path (or ctx.path) and
if it startsWith '/api' then set an appropriate API-style response (e.g.,
ctx.status = 404; ctx.body = { error: 'Not Found' }; return) so API GETs are not
swallowed by the SPA fallback; alternatively, adjust the HTTPMethod path to
explicitly exclude '/api' paths if your router supports that.

}
}
}
57 changes: 57 additions & 0 deletions mini-muse/app/controller/MuseAgentController.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
Verify each finding against the current code and only fix it if needed.

In `@mini-muse/app/controller/MuseAgentController.ts` around lines 13 - 27, In
createStore(), validate required OSS configuration env vars (at minimum
OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET and OSS_BUCKET; optionally
OSS_REGION/OSS_ENDPOINT) before constructing
OSSObject/OSSObjectStorageClient/OSSAgentStore: check each required variable is
non-empty and if any are missing, throw or return a descriptive error (e.g.,
"missing OSS_ACCESS_KEY_ID") to fail fast rather than building a client with
empty credentials; perform this validation at the top of the createStore()
method and only construct OSSObject, OSSObjectStorageClient, and OSSAgentStore
when all validations pass.


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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject empty or non-text run inputs up front.

Line 34 assumes at least one message exists and that every content part has a text field. Malformed requests will throw here instead of returning a controlled bad-request error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mini-muse/app/controller/MuseAgentController.ts` around lines 33 - 37, The
execRun implementation assumes input.input.messages[0] exists and that content
parts have a text field; validate input in execRun before using userMessage:
check that input?.input?.messages is an array with at least one element, that
the first message has a content property which is either a string or an array,
and if it's an array ensure every element has a text string; if any of these
checks fail, throw or return a controlled bad-request error (e.g., HTTP 400 / a
specific error) rather than letting userMessage/description access throw. Ensure
you update references to userMessage and description to use the validated
values.


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,
});
}
}
110 changes: 110 additions & 0 deletions mini-muse/app/controller/ProjectController.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Harden threadId and filePath path resolution.

threadId is interpolated directly into ./output/${threadId}, so values like .. escape the output root, and the startsWith() check on filePath is bypassable by sibling-prefix paths such as ../app2/... when the project dir is app. That exposes arbitrary file reads/zips and lets the preview endpoint run commands outside the intended project directory.

Use a shared resolveProjectDir() helper that resolves against path.resolve('output') and rejects any path.relative() that is empty, absolute, or starts with .., then apply the same path.relative() check to filePath.

Also applies to: 43-46, 62-64, 83-86

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mini-muse/app/controller/ProjectController.ts` around lines 23 - 24, The
threadId interpolation allows path traversal; add a shared helper
resolveProjectDir(threadId) that does: base = path.resolve('output'), projectDir
= path.resolve(base, threadId), rel = path.relative(base, projectDir) and reject
when rel === '' || path.isAbsolute(rel) || rel.startsWith('..'); use this helper
wherever project paths are built (the files method and the other similar blocks
referenced around lines 43-46, 62-64, 83-86) and apply the same path.relative
check to any derived filePath before reading/zipping to ensure filePath remains
inside the resolved projectDir.

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 },
};
}
}
Loading