commit 74a8f7cdf367c96480ed906b390f2b2115832cb7 Author: Codex Bot Date: Thu Sep 17 21:54:33 2026 +0800 feat: add agent-first minitool studio MVP diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7ba7181 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - name: Install dependencies + run: npm ci + - name: Type check + run: npm run typecheck + - name: Unit tests + run: npm test + - name: Production build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55d6218 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +data/projects/* +!data/projects/.gitkeep +.DS_Store +*.log diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md new file mode 100644 index 0000000..384151c --- /dev/null +++ b/AGENT_GUIDE.md @@ -0,0 +1,39 @@ +# Codex 接入说明 + +Rednote Tool Studio 是确定性的项目、构建与校验服务,不内嵌任何 LLM。Codex、Claude Code 或其他编码代理通过 HTTP 操作它。 + +## 建议工作流 + +1. `POST /api/projects` 创建项目,保存响应中的 `id` 和 `revision`。 +2. `GET /api/projects/{id}` 获取文件列表。 +3. 逐个读取必要文件,避免覆盖用户未要求修改的内容。 +4. 使用 `PUT /files/{path}` 或 `POST /patch` 写入,并始终传当前 `revision`。 +5. 若修改了 `package.json`,调用 `POST /dependencies/install`。安装过程禁用依赖生命周期脚本。 +6. `POST /build` 后读取结构化报告;根据 `code`、`file`、`line` 和 `suggestion` 修复。 +7. `POST /validate` 直到 `status` 为 `passed`。 +8. `POST /package`,将 `artifactUrl` 返回给用户。 + +完整接口契约位于 `GET /openapi.json`。 + +## 约束 + +- 小工具纯本地运行,不调用网络接口,不加载任何外链资源。 +- 不使用内联脚本、行内事件、`type="module"`、`import()`、Web Worker、WASM 或受限设备 API。 +- 源码可以用 React JSX;服务会将最终代码打包成面向 Chrome 61 的 ES2017 经典脚本。 +- `index.html` 必须位于 ZIP 根目录。 +- 修改失败并返回 HTTP 409 时,重新获取项目与文件,再基于最新 revision 应用改动。 +- 校验通过表示静态门禁通过,不等同于小红书模拟器或真机验收。 + +## 示例 + +```bash +curl -X POST http://localhost:3000/api/projects \ + -H 'Content-Type: application/json' \ + -d '{"name":"周末灵感签","template":"basic"}' + +curl -X POST http://localhost:3000/api/projects/PROJECT_ID/package \ + -H 'Content-Type: application/json' \ + -d '{}' +``` + +不要把服务暴露到不可信网络。后续部署到共享环境时,应在 API 前增加身份认证、项目授权和请求限流。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..24f9cde --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Rednote Tool Studio + +一个面向编码代理的小红书小工具制作器。界面使用 Next.js + React,外部 Codex、Claude Code 或其他代理通过 HTTP API 创建、修改、预览、校验并导出离线 H5 ZIP;应用本身不内嵌 LLM,也不持有模型密钥。 + +## MVP 能力 + +- 项目管理页面、源码编辑器与手机预览 +- 文件读写和批量修改 API,使用 revision 避免并发覆盖 +- 可发现的 OpenAPI 3.1 契约 +- React 小工具模板 +- 项目级依赖安装(禁用 npm 生命周期脚本) +- ES2017 / Chrome 61 经典脚本构建 +- 受限 API、HTML/CSP、文件结构与资源检查 +- 固定版本 `minitool-zip-builder 1.6.0` 审计 +- 校验通过后生成 ZIP +- 闲置项目 `node_modules` 自动清理 + +## 本地运行 + +```bash +npm install +npm run dev +``` + +访问 `http://localhost:3000`。API 契约位于 `http://localhost:3000/openapi.json`,编码代理说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md)。 + +## 闲置依赖清理 + +服务启动后每小时扫描一次托管项目。项目超过 24 小时没有 API、预览或构建访问时,会删除: + +```text +data/projects//workspace/node_modules +``` + +源码、锁文件、构建产物和 ZIP 不会被删除;正在构建的项目不会被清理。需要依赖时可依据锁文件重新安装。可通过环境变量调整: + +```bash +NODE_MODULES_IDLE_HOURS=48 CLEANUP_INTERVAL_MS=3600000 npm run dev +``` + +也可显式调用: + +```bash +curl -X POST http://localhost:3000/api/maintenance/cleanup \ + -H 'Content-Type: application/json' \ + -d '{"idleHours":24}' +``` + +## 校验边界 + +内置 skill 的审计脚本主要检查包体和文本文件体积。Studio 在它之外增加 HTML、受限 API、外链和产物结构检查。静态检查通过仍不代表已经完成小红书模拟器或真机验收。 + +仓库固定包含 `minitool-zip-builder 1.6.0`,来源为: + +```text +https://fe-static.xhscdn.com/mini-tool/20260831163932/minitool-zip-builder-1.6.0.skill +SHA-256 29c04115fd89d7eab7b81775f4287ae20c569ad3794d25d8404dc0ec3ec3b65e +``` + +## 测试 + +```bash +npm run typecheck +npm test +npm run build +``` diff --git a/app/api/artifacts/[projectId]/[file]/route.ts b/app/api/artifacts/[projectId]/[file]/route.ts new file mode 100644 index 0000000..807decd --- /dev/null +++ b/app/api/artifacts/[projectId]/[file]/route.ts @@ -0,0 +1,17 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { NextResponse } from "next/server"; +import { errorResponse } from "../../../../../lib/http"; +import { projectRoot, touchProject } from "../../../../../lib/storage"; + +export const runtime = "nodejs"; + +export async function GET(_: Request, context: { params: Promise<{ projectId: string; file: string }> }) { + try { + const { projectId, file } = await context.params; + if (file !== "minitool.zip") throw new Error("Unknown artifact"); + await touchProject(projectId); + const data = await fs.readFile(path.join(projectRoot(projectId), "artifacts", file)); + return new NextResponse(data, { headers: { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="${projectId}-minitool.zip"` } }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/maintenance/cleanup/route.ts b/app/api/maintenance/cleanup/route.ts new file mode 100644 index 0000000..2e42a91 --- /dev/null +++ b/app/api/maintenance/cleanup/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cleanupIdleNodeModules } from "../../../../lib/cleanup"; +import { errorResponse } from "../../../../lib/http"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const idleHours = body.idleHours === undefined ? undefined : Number(body.idleHours); + if (idleHours !== undefined && (!Number.isFinite(idleHours) || idleHours < 0)) throw new Error("idleHours must be a non-negative number"); + return NextResponse.json(await cleanupIdleNodeModules(idleHours)); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/[id]/build/route.ts b/app/api/projects/[id]/build/route.ts new file mode 100644 index 0000000..5fcb1f2 --- /dev/null +++ b/app/api/projects/[id]/build/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { buildProject } from "../../../../../lib/build"; +import { errorResponse } from "../../../../../lib/http"; +import { touchProject } from "../../../../../lib/storage"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function POST(_: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + await touchProject(id); + const result = await buildProject(id); + return NextResponse.json({ report: result.report, previewUrl: `/api/projects/${id}/preview/index.html` }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/[id]/dependencies/install/route.ts b/app/api/projects/[id]/dependencies/install/route.ts new file mode 100644 index 0000000..42f5778 --- /dev/null +++ b/app/api/projects/[id]/dependencies/install/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { installProjectDependencies } from "../../../../../../../lib/build"; +import { errorResponse } from "../../../../../../../lib/http"; +import { touchProject } from "../../../../../../../lib/storage"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +export async function POST(_: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + await touchProject(id); + return NextResponse.json(await installProjectDependencies(id)); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/[id]/files/[...path]/route.ts b/app/api/projects/[id]/files/[...path]/route.ts new file mode 100644 index 0000000..154cc65 --- /dev/null +++ b/app/api/projects/[id]/files/[...path]/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "../../../../../../lib/http"; +import { readProjectFile, writeProjectFiles } from "../../../../../../lib/storage"; + +export const runtime = "nodejs"; + +type Context = { params: Promise<{ id: string; path: string[] }> }; + +export async function GET(_: NextRequest, context: Context) { + try { + const params = await context.params; + return NextResponse.json({ content: await readProjectFile(params.id, params.path) }); + } catch (error) { return errorResponse(error); } +} + +export async function PUT(request: NextRequest, context: Context) { + try { + const params = await context.params; + const body = await request.json(); + if (typeof body.content !== "string") throw new Error("content must be a string"); + const project = await writeProjectFiles(params.id, [{ path: params.path.join("/"), content: body.content }], body.revision); + return NextResponse.json({ project }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/[id]/package/route.ts b/app/api/projects/[id]/package/route.ts new file mode 100644 index 0000000..5b79a42 --- /dev/null +++ b/app/api/projects/[id]/package/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { packageProject } from "../../../../../lib/build"; +import { errorResponse } from "../../../../../lib/http"; +import { touchProject } from "../../../../../lib/storage"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function POST(_: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + await touchProject(id); + const result = await packageProject(id); + return NextResponse.json({ report: result.report, artifactUrl: result.zipPath ? `/api/artifacts/${id}/minitool.zip` : undefined }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/[id]/patch/route.ts b/app/api/projects/[id]/patch/route.ts new file mode 100644 index 0000000..9ea9e41 --- /dev/null +++ b/app/api/projects/[id]/patch/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "../../../../../lib/http"; +import { writeProjectFiles } from "../../../../../lib/storage"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const body = await request.json(); + if (!Array.isArray(body.changes) || body.changes.some((item: unknown) => !item || typeof (item as { path?: unknown }).path !== "string" || typeof (item as { content?: unknown }).content !== "string")) throw new Error("changes must contain path and content strings"); + return NextResponse.json({ project: await writeProjectFiles(id, body.changes, body.revision) }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/[id]/preview/[[...path]]/route.ts b/app/api/projects/[id]/preview/[[...path]]/route.ts new file mode 100644 index 0000000..ac4f17c --- /dev/null +++ b/app/api/projects/[id]/preview/[[...path]]/route.ts @@ -0,0 +1,23 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { NextResponse } from "next/server"; +import { buildProject } from "../../../../../../lib/build"; +import { errorResponse } from "../../../../../../lib/http"; +import { projectRoot, touchProject } from "../../../../../../lib/storage"; + +export const runtime = "nodejs"; + +const CONTENT_TYPES: Record = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".woff": "font/woff", ".woff2": "font/woff2" }; + +export async function GET(_: Request, context: { params: Promise<{ id: string; path?: string[] }> }) { + try { + const params = await context.params; + await touchProject(params.id); + const segments = params.path?.length ? params.path : ["index.html"]; + const dist = path.join(projectRoot(params.id), "dist"); + let target = path.resolve(dist, ...segments); + if (!target.startsWith(`${dist}${path.sep}`)) throw new Error("Invalid preview path"); + try { await fs.access(target); } catch { await buildProject(params.id); target = path.resolve(dist, ...segments); } + return new NextResponse(await fs.readFile(target), { headers: { "Content-Type": CONTENT_TYPES[path.extname(target)] || "application/octet-stream", "Cache-Control": "no-store" } }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..7658ca7 --- /dev/null +++ b/app/api/projects/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { errorResponse } from "../../../../lib/http"; +import { getProject, listFiles } from "../../../../lib/storage"; + +export const runtime = "nodejs"; + +export async function GET(_: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const [project, files] = await Promise.all([getProject(id), listFiles(id)]); + return NextResponse.json({ project, files }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/[id]/validate/route.ts b/app/api/projects/[id]/validate/route.ts new file mode 100644 index 0000000..fcce965 --- /dev/null +++ b/app/api/projects/[id]/validate/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { buildProject } from "../../../../../lib/build"; +import { errorResponse } from "../../../../../lib/http"; +import { touchProject } from "../../../../../lib/storage"; + +export const runtime = "nodejs"; + +export async function POST(_: Request, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + await touchProject(id); + const result = await buildProject(id); + return NextResponse.json({ report: result.report }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..134459f --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createProject, listProjects } from "../../../lib/storage"; +import { errorResponse } from "../../../lib/http"; + +export const runtime = "nodejs"; + +export async function GET() { + try { return NextResponse.json({ projects: await listProjects() }); } + catch (error) { return errorResponse(error); } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + return NextResponse.json({ project: await createProject(body) }, { status: 201 }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..1ef0928 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,58 @@ +:root { color-scheme: light; --ink: #25372f; --muted: #7a827e; --line: #dde1dc; --cream: #f6f4ee; --paper: #fffefa; --green: #315542; --red: #ff2442; } +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--cream); color: var(--ink); font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; } +button, textarea, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +.studio-shell { min-height: 100vh; } +.topbar { height: 66px; padding: 0 20px; display: flex; align-items: center; justify-content: space-between; background: var(--paper); border-bottom: 1px solid var(--line); } +.topbar > div { display: flex; align-items: center; gap: 10px; } +.topbar small { color: var(--muted); padding-left: 10px; border-left: 1px solid var(--line); } +.logo { width: 31px; height: 31px; display: grid; place-items: center; color: white; background: var(--red); border-radius: 10px; font-weight: 800; } +.actions a, .actions button, .panel-head button, .command-grid button { border: 1px solid var(--line); background: white; color: var(--ink); border-radius: 8px; padding: 9px 13px; text-decoration: none; } +.actions button, .command-grid .primary { color: white; background: var(--green); border-color: var(--green); } +.workspace { display: grid; grid-template-columns: 190px minmax(330px, 1fr) minmax(330px, 430px) 290px; height: calc(100vh - 66px); } +.sidebar, .editor-panel, .preview-panel, .inspect-panel { min-width: 0; border-right: 1px solid var(--line); background: var(--paper); } +.sidebar, .inspect-panel { padding: 18px 12px; overflow-y: auto; } +.section-label { margin: 0 8px 10px; color: #929995; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.files-label { margin-top: 28px; } +.project-row, .file-row, .empty-project { width: 100%; border: 0; background: transparent; border-radius: 8px; text-align: left; padding: 10px; color: #526059; } +.project-row { display: flex; justify-content: space-between; gap: 8px; } +.project-row small { color: #9aa19d; } +.project-row.selected, .file-row.selected { background: #e8eee9; color: var(--green); font-weight: 650; } +.file-row { padding: 7px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } +.empty-project { border: 1px dashed #bbc4be; text-align: center; } +.editor-panel, .preview-panel { display: flex; flex-direction: column; background: #eef0ec; } +.panel-head { height: 58px; flex: 0 0 auto; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; background: var(--paper); border-bottom: 1px solid var(--line); } +.panel-head div { min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.panel-head strong, .panel-head small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.panel-head small { color: var(--muted); font-size: 10px; } +.editor-panel textarea { flex: 1; resize: none; border: 0; outline: none; padding: 20px; background: #17201c; color: #dce9df; font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 2; } +.statusbar { height: 32px; background: white; border-top: 1px solid var(--line); color: var(--muted); display: flex; align-items: center; gap: 7px; padding: 0 12px; font-size: 11px; } +.ok-dot, .error-dot { width: 7px; height: 7px; border-radius: 50%; background: #54a56d; } +.error-dot { background: #d95454; } +.preview-panel { align-items: center; overflow: auto; } +.preview-panel .panel-head { width: 100%; } +.phone { position: relative; flex: 0 0 auto; width: 330px; height: 660px; margin: 24px; padding: 12px 8px 8px; background: #202622; border: 4px solid #3b423e; border-radius: 37px; box-shadow: 0 16px 45px rgba(28, 45, 37, .18); } +.phone iframe { width: 100%; height: 100%; border: 0; border-radius: 25px; background: white; } +.speaker { position: absolute; z-index: 2; top: 6px; left: 50%; width: 55px; height: 5px; transform: translateX(-50%); border-radius: 5px; background: #090b0a; } +.inspect-panel { border-right: 0; } +.command-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.command-grid button { font-size: 12px; padding: 10px 4px; } +.report, .report-placeholder, .agent-note { margin-top: 18px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8; } +.report { display: flex; flex-direction: column; gap: 5px; } +.report.passed { border-color: #b9d4bf; background: #f2f8f2; } +.report.failed { border-color: #e1bcbc; background: #fff7f6; } +.report > small, .report article span { color: var(--muted); font-size: 10px; } +.report pre { margin: 9px 0; white-space: pre-wrap; font-size: 10px; } +.report article { border-top: 1px solid var(--line); padding-top: 10px; margin-top: 6px; } +.report article b { display: block; color: #bd4545; font-size: 11px; } +.report article p { font-size: 12px; margin: 7px 0; } +.report-placeholder { color: var(--muted); text-align: center; font-size: 12px; line-height: 1.7; } +.report-placeholder span { display: inline-grid; place-items: center; width: 35px; height: 35px; border: 1px solid #bbccc1; border-radius: 50%; color: var(--green); } +.agent-note { margin-top: 26px; background: #eef2ee; border: 0; } +.agent-note strong { font-size: 12px; } +.agent-note p { color: #68736d; font-size: 11px; line-height: 1.7; } +button:disabled { cursor: not-allowed; opacity: .55; } +@media (max-width: 1180px) { .workspace { grid-template-columns: 170px minmax(320px, 1fr) 380px; } .inspect-panel { display: none; } } +@media (max-width: 820px) { .workspace { grid-template-columns: 150px 1fr; } .preview-panel { display: none; } .topbar small { display: none; } } diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..7c780ef --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "小红书小工具 Studio", + description: "通过 HTTP API 让 Codex 等编码代理创建、预览、检查并打包小红书小工具。", +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return {children}; +} diff --git a/app/openapi.json/route.ts b/app/openapi.json/route.ts new file mode 100644 index 0000000..1099bdc --- /dev/null +++ b/app/openapi.json/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; + +export function GET(request: Request) { + const origin = new URL(request.url).origin; + return NextResponse.json({ + openapi: "3.1.0", + info: { title: "Rednote Tool Studio API", version: "0.1.0", description: "供 Codex 等外部编码代理创建、修改、构建、校验并打包小红书离线 H5 小工具。写操作使用 revision 进行乐观并发控制。" }, + servers: [{ url: origin }], + paths: { + "/api/projects": { + get: { operationId: "listProjects", summary: "列出项目", responses: { "200": { description: "项目列表" } } }, + post: { operationId: "createProject", summary: "从模板创建项目", requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, template: { type: "string", default: "basic" } } } } } }, responses: { "201": { description: "已创建" } } }, + }, + "/api/projects/{id}": { get: { operationId: "getProject", summary: "读取项目元数据与文件列表", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "项目详情" } } } }, + "/api/projects/{id}/files/{path}": { + get: { operationId: "readProjectFile", summary: "读取 UTF-8 源文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], responses: { "200": { description: "文件内容" } } }, + put: { operationId: "writeProjectFile", summary: "写入单个源文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/FileWrite" } } } }, responses: { "200": { description: "已保存" }, "409": { description: "revision 冲突" } } }, + }, + "/api/projects/{id}/patch": { post: { operationId: "patchProjectFiles", summary: "原子地写入多个文件", parameters: [{ $ref: "#/components/parameters/ProjectId" }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["revision", "changes"], properties: { revision: { type: "integer" }, changes: { type: "array", items: { type: "object", required: ["path", "content"], properties: { path: { type: "string" }, content: { type: "string" } } } } } } } } }, responses: { "200": { description: "已更新" }, "409": { description: "revision 冲突" } } } }, + "/api/projects/{id}/build": { post: { operationId: "buildProject", summary: "构建 ES2017 / Chrome 61 离线产物", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "构建结果和预览地址" } } } }, + "/api/projects/{id}/dependencies/install": { post: { operationId: "installProjectDependencies", summary: "根据 package.json 安装项目依赖;禁用生命周期脚本", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "安装日志" } } } }, + "/api/projects/{id}/validate": { post: { operationId: "validateProject", summary: "构建并运行语义检查与 skill 审计", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "结构化校验报告" } } } }, + "/api/projects/{id}/package": { post: { operationId: "packageProject", summary: "校验并生成 ZIP", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "校验报告和 artifactUrl;失败时不生成 ZIP" } } } }, + "/api/projects/{id}/preview/{path}": { get: { operationId: "previewArtifact", summary: "读取预览产物", parameters: [{ $ref: "#/components/parameters/ProjectId" }, { $ref: "#/components/parameters/FilePath" }], responses: { "200": { description: "静态产物" } } } }, + "/api/maintenance/cleanup": { post: { operationId: "cleanupIdleNodeModules", summary: "清理闲置项目 node_modules", requestBody: { content: { "application/json": { schema: { type: "object", properties: { idleHours: { type: "number", minimum: 0, default: 24 } } } } } }, responses: { "200": { description: "清理结果" } } } }, + }, + components: { + parameters: { + ProjectId: { name: "id", in: "path", required: true, schema: { type: "string" } }, + FilePath: { name: "path", in: "path", required: true, schema: { type: "string" }, description: "项目相对路径,例如 src/App.jsx" }, + }, + schemas: { + FileWrite: { type: "object", required: ["content", "revision"], properties: { content: { type: "string" }, revision: { type: "integer", description: "来自项目元数据的当前 revision" } } }, + }, + }, + }); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..9c69c11 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import Studio from "../components/Studio"; + +export default function Home() { + return ; +} diff --git a/components/Studio.tsx b/components/Studio.tsx new file mode 100644 index 0000000..287c85d --- /dev/null +++ b/components/Studio.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +type Project = { id: string; name: string; revision: number; updatedAt: string; nodeModulesCleanedAt?: string }; +type Report = { status: "passed" | "failed"; skillVersion: string; auditOutput: string; issues: Array<{ severity: string; code: string; file: string; line?: number; message: string; suggestion: string }>; limits: { zipBytes?: number; unpackedBytes: number } }; + +async function api(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers || {}) } }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || `请求失败:${response.status}`); + return data; +} + +export default function Studio() { + const [projects, setProjects] = useState([]); + const [selected, setSelected] = useState(null); + const [files, setFiles] = useState([]); + const [activeFile, setActiveFile] = useState("src/App.jsx"); + const [content, setContent] = useState(""); + const [report, setReport] = useState(null); + const [busy, setBusy] = useState(""); + const [message, setMessage] = useState("连接已就绪"); + const [previewKey, setPreviewKey] = useState(0); + + const loadProjects = useCallback(async () => { + const data = await api<{ projects: Project[] }>("/api/projects"); + setProjects(data.projects); + if (!selected && data.projects[0]) setSelected(data.projects[0]); + }, [selected]); + + useEffect(() => { void loadProjects(); }, [loadProjects]); + useEffect(() => { + if (!selected) return; + void api<{ files: string[] }>(`/api/projects/${selected.id}`).then((data) => setFiles(data.files)); + }, [selected]); + useEffect(() => { + if (!selected || !activeFile) return; + void api<{ content: string }>(`/api/projects/${selected.id}/files/${activeFile}`).then((data) => setContent(data.content)); + }, [selected, activeFile]); + + const previewUrl = useMemo(() => selected ? `/api/projects/${selected.id}/preview/index.html?v=${previewKey}` : "about:blank", [selected, previewKey]); + + async function createProject() { + setBusy("create"); + try { + const data = await api<{ project: Project }>("/api/projects", { method: "POST", body: JSON.stringify({ name: `灵感小工具 ${projects.length + 1}`, template: "basic" }) }); + setSelected(data.project); setReport(null); setMessage("项目已创建"); await loadProjects(); + } catch (error) { setMessage((error as Error).message); } finally { setBusy(""); } + } + + async function save() { + if (!selected) return; + setBusy("save"); + try { + const data = await api<{ project: Project }>(`/api/projects/${selected.id}/files/${activeFile}`, { method: "PUT", body: JSON.stringify({ content, revision: selected.revision }) }); + setSelected(data.project); setMessage(`已保存 · revision ${data.project.revision}`); + } catch (error) { setMessage((error as Error).message); } finally { setBusy(""); } + } + + async function run(action: "build" | "validate" | "package") { + if (!selected) return; + setBusy(action); + try { + const data = await api<{ report: Report; artifactUrl?: string }>(`/api/projects/${selected.id}/${action}`, { method: "POST", body: "{}" }); + setReport(data.report); setPreviewKey((value) => value + 1); + setMessage(data.report.status === "passed" ? `${action} 已通过` : `${action} 发现 ${data.report.issues.length} 个问题`); + if (data.artifactUrl) setMessage(`ZIP 已生成:${data.artifactUrl}`); + } catch (error) { setMessage((error as Error).message); } finally { setBusy(""); } + } + + return
+
+
RRednote Tool StudioAgent-first 小工具工作台
+
OpenAPI
+
+
+ +
+
{activeFile}{selected ? selected.id : "未选择项目"}
+