diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 671c2ab..6c64956 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -1,41 +1,29 @@ -# Codex 接入说明 +# 编码代理接入说明 -Rednote Tool Studio 是确定性的项目、构建与校验服务,不内嵌任何 LLM。Codex、Claude Code 或其他编码代理通过 HTTP 操作它。 +Rednote Content Studio 正在改造成小红书内容生成工作台。最高层级对象为“项目”,每个项目定义一类内容共用的生成方法。 -生产环境使用 HTTP Basic Auth。用户名默认为 `studio`,密码由部署方保存在 `STUDIO_PASSWORD` 中。代理调用所有业务 API 时都应发送 `Authorization` 请求头;`GET /api/health` 是唯一无需认证的接口。 +## 当前可用接口 -## 建议工作流 +- `GET /api/projects`:列出项目 +- `POST /api/projects`:创建项目 +- `GET /api/projects/{id}`:读取项目 +- `PUT /api/projects/{id}`:更新项目及其生成方法 +- `GET /api/health`:健康检查 -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` 返回给用户。 +更新项目时携带当前 `revision`。版本冲突会返回 HTTP 409,应重新读取项目后再修改。 -完整接口契约位于 `GET /openapi.json`。 +## 项目生成方法 -## 约束 +`recipe` 当前包含: -- 小工具纯本地运行,不调用网络接口,不加载任何外链资源。 -- 不使用内联脚本、行内事件、`type="module"`、`import()`、Web Worker、WASM 或受限设备 API。 -- 源码可以用 React JSX;服务会将最终代码打包成面向 Chrome 61 的 ES2017 经典脚本。 -- `index.html` 必须位于 ZIP 根目录。 -- 修改失败并返回 HTTP 409 时,重新获取项目与文件,再基于最新 revision 应用改动。 -- 校验通过表示静态门禁通过,不等同于小红书模拟器或真机验收。 +- `positioning`:内容定位 +- `audience`:目标受众 +- `tone`:表达语气 +- `contentFormat`:内容形式 +- `titleMethod`:标题方法 +- `bodyStructure`:正文结构 +- `defaultTags`:默认标签 -## 示例 +内容草稿和实际生成接口尚未实现。不要把旧版小工具的 build、validate、package 接口当作新的内容生成能力。 -```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 前增加身份认证、项目授权和请求限流。 +生产环境使用 HTTP Basic Auth。用户名默认为 `studio`,密码由 `STUDIO_PASSWORD` 提供;`GET /api/health` 无需认证。 diff --git a/Dockerfile b/Dockerfile index 0bbaabf..83e0a77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ PORT=3000 \ HOSTNAME=0.0.0.0 -RUN groupadd --system --gid 1001 studio && useradd --system --uid 1001 --gid studio studio +RUN groupadd --system --gid 1001 studio && useradd --system --create-home --home-dir /home/studio --uid 1001 --gid studio studio COPY --from=builder --chown=studio:studio /app/.next/standalone ./ COPY --from=builder --chown=studio:studio /app/.next/static ./.next/static COPY --from=builder --chown=studio:studio /app/templates ./templates diff --git a/Dockerfile.deploy b/Dockerfile.deploy index 90b1f4d..06c1688 100644 --- a/Dockerfile.deploy +++ b/Dockerfile.deploy @@ -4,7 +4,7 @@ ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ PORT=3000 \ HOSTNAME=0.0.0.0 -RUN groupadd --system --gid 1001 studio && useradd --system --uid 1001 --gid studio studio +RUN groupadd --system --gid 1001 studio && useradd --system --create-home --home-dir /home/studio --uid 1001 --gid studio studio COPY --chown=studio:studio .next/standalone ./ COPY --chown=studio:studio .next/static ./.next/static COPY --chown=studio:studio templates ./templates diff --git a/README.md b/README.md index e566273..5d39d79 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,32 @@ -# Rednote Tool Studio +# Rednote Content 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` 自动清理 -- 生产环境 Basic Auth、Docker 健康检查、持久化部署与失败回滚 +## 当前阶段 + +仓库目前只完成起步结构: + +- 项目列表与新建项目 +- 项目基本信息 +- 可复用的内容生成方法配置 +- revision 乐观并发控制 +- 下一阶段内容生成流程的界面占位 +- 生产环境 Basic Auth、Docker 镜像与持久化目录 + +实际的选题简报、文案生成、草稿管理、AI 模型接入和导出尚未实现。 + +## 计划中的内容层级 + +```text +项目(最高层级,一套固定生成方法) +└── 内容草稿 + ├── 选题简报 + ├── 标题候选 + ├── 正文 + └── 标签 +``` ## 本地运行 @@ -23,55 +35,16 @@ npm install npm run dev ``` -访问 `http://localhost:3000`。API 契约位于 `http://localhost:3000/openapi.json`,编码代理说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md)。 +访问 `http://localhost:3000`。开发环境未配置 `STUDIO_PASSWORD` 时允许本地访问;生产环境必须提供密码。 -开发模式未配置 `STUDIO_PASSWORD` 时允许本地访问;生产模式必须提供密码,否则业务页面和 API 返回 503。`GET /api/health` 始终公开,用于容器健康检查。 - -## 持续部署 - -`main` 分支的 CI 全部通过后,会通过 runner 挂载的 Docker socket 构建并部署 `rednote-tool-studio` 容器。默认映射宿主机端口 `3000`,使用命名卷 `rednote-tool-studio-data` 保存项目。 - -仓库 Actions Secret 必须包含 `STUDIO_PASSWORD`。可选仓库变量 `DEPLOY_PORT` 用于覆盖宿主机端口。部署先停止并保留旧容器,候选容器健康后才删除旧容器;候选失败时恢复旧版本。 - -首次配置时,在 Gitea 仓库的 `Settings → Actions → Secrets` 创建 `STUDIO_PASSWORD`。若本机由 Codex 生成过密码,可从 macOS Keychain 的 service `rednote-tool-studio`、account `studio` 读取;不要把密码提交到仓库或粘贴到聊天中。Secret 尚未配置时,CI 会完成全部验证并安全跳过部署。 - -## 闲置依赖清理 - -服务启动后每小时扫描一次托管项目。项目超过 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 ``` + +## 当前过渡说明 + +仓库中仍保留上一版“小红书离线 H5 小工具制作器”的构建和校验模块,但新界面不再使用它们。后续实现内容生成链路时再做一次数据与代码清理,避免在起步阶段同时改动过多基础设施。 diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts index 7658ca7..9529993 100644 --- a/app/api/projects/[id]/route.ts +++ b/app/api/projects/[id]/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { errorResponse } from "../../../../lib/http"; -import { getProject, listFiles } from "../../../../lib/storage"; +import { getProject, listFiles, updateProject } from "../../../../lib/storage"; export const runtime = "nodejs"; @@ -11,3 +11,10 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }> return NextResponse.json({ project, files }); } catch (error) { return errorResponse(error); } } + +export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + return NextResponse.json({ project: await updateProject(id, await request.json()) }); + } catch (error) { return errorResponse(error); } +} diff --git a/app/globals.css b/app/globals.css index 1ef0928..93d2b7e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,58 +1,57 @@ -:root { color-scheme: light; --ink: #25372f; --muted: #7a827e; --line: #dde1dc; --cream: #f6f4ee; --paper: #fffefa; --green: #315542; --red: #ff2442; } +:root { color-scheme: light; --ink: #242321; --muted: #7e7a73; --line: #e8e2d9; --paper: #fffdf9; --canvas: #f5f1ea; --red: #ff2442; --red-soft: #fff0f2; } * { 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; } +html, body { margin: 0; min-height: 100%; background: var(--canvas); color: var(--ink); font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif; } +button, input, textarea, select { font: inherit; } 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; } +.studio-shell { min-height: 100vh; padding-bottom: 34px; } +.topbar { height: 72px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; background: var(--paper); border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: 12px; } +.brand > div { display: flex; flex-direction: column; gap: 3px; } +.brand small { color: var(--muted); font-size: 11px; } +.logo { width: 34px; height: 34px; display: grid; place-items: center; color: white; background: var(--red); border-radius: 11px; font-weight: 850; } +.primary { border: 0; border-radius: 9px; padding: 10px 16px; background: var(--red); color: white; font-weight: 650; } +.workspace { min-height: calc(100vh - 106px); display: grid; grid-template-columns: 230px minmax(540px, 1fr) 300px; } +.sidebar { padding: 22px 14px; background: var(--paper); border-right: 1px solid var(--line); } +.section-head { margin: 0 8px 12px; display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 750; } +.section-head small { color: var(--muted); font-size: 10px; font-weight: 500; } +.project-row, .empty-project { width: 100%; padding: 11px 12px; border: 0; border-radius: 9px; background: transparent; color: #595550; text-align: left; } .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; } } +.project-row small { color: #aaa39a; } +.project-row.selected { background: var(--red-soft); color: #b81830; font-weight: 700; } +.empty-project { border: 1px dashed #cec4b8; text-align: center; } +.sidebar-note { margin-top: 28px; padding: 14px; border-radius: 12px; background: #f7f3ed; } +.sidebar-note b { font-size: 12px; } +.sidebar-note p { margin: 7px 0 0; color: var(--muted); font-size: 11px; line-height: 1.7; } +.project-editor { padding: 34px clamp(24px, 4vw, 60px) 60px; overflow-y: auto; } +.page-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; } +.page-title span, .eyebrow { color: var(--red); font-size: 11px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } +.page-title h1 { margin: 7px 0 6px; font-size: 30px; } +.page-title p { margin: 0; color: var(--muted); } +.form-card { margin-top: 16px; padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--paper); box-shadow: 0 8px 30px rgba(72, 58, 39, .035); } +.form-card h2 { margin: 0 0 18px; font-size: 16px; } +.card-title { display: flex; justify-content: space-between; align-items: flex-start; } +.card-title h2 { margin-bottom: 4px; } +.card-title p { margin: 0 0 18px; color: var(--muted); font-size: 12px; } +.status-pill { padding: 5px 9px; border-radius: 20px; background: #edf7ef; color: #407b4c; font-size: 10px; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +label { display: flex; flex-direction: column; gap: 7px; margin-top: 14px; color: #5f5a54; font-size: 12px; font-weight: 650; } +input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 9px; padding: 10px 11px; outline: none; background: #fff; color: var(--ink); font-weight: 400; resize: vertical; } +input:focus, textarea:focus, select:focus { border-color: #ee8494; box-shadow: 0 0 0 3px var(--red-soft); } +.next-panel { padding: 34px 24px; background: #292724; color: white; } +.next-panel h2 { margin: 8px 0; font-size: 24px; } +.next-panel > p { margin: 0 0 30px; color: #bcb6ae; font-size: 12px; line-height: 1.7; } +.future-step { display: flex; gap: 12px; padding: 16px 0; border-top: 1px solid #45413c; } +.future-step b { color: #817b73; font-size: 11px; } +.future-step span { display: flex; flex-direction: column; gap: 5px; } +.future-step strong { font-size: 13px; } +.future-step small { color: #99928a; } +.next-panel button { width: 100%; margin-top: 24px; padding: 12px; border: 1px solid #4d4943; border-radius: 9px; background: #36332f; color: #827c74; } +.welcome { grid-column: 2 / 4; display: grid; place-content: center; justify-items: center; text-align: center; } +.welcome-mark { font-size: 30px; color: var(--red); } +.welcome h1 { margin-bottom: 4px; } +.welcome p { margin: 0 0 22px; color: var(--muted); } +.statusbar { position: fixed; z-index: 5; left: 0; right: 0; bottom: 0; height: 34px; display: flex; align-items: center; gap: 8px; padding: 0 15px; background: var(--paper); border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; } +.statusbar span { width: 7px; height: 7px; border-radius: 50%; background: #55a26a; } +button:disabled { cursor: not-allowed; opacity: .6; } +@media (max-width: 1050px) { .workspace { grid-template-columns: 210px 1fr; } .next-panel { display: none; } } +@media (max-width: 720px) { .workspace { display: block; } .sidebar { border-right: 0; border-bottom: 1px solid var(--line); } .sidebar-note { display: none; } .form-grid { grid-template-columns: 1fr; } .page-title { flex-direction: column; } } diff --git a/app/layout.tsx b/app/layout.tsx index 7c780ef..14a1629 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "小红书小工具 Studio", - description: "通过 HTTP API 让 Codex 等编码代理创建、预览、检查并打包小红书小工具。", + title: "Rednote Content Studio", + description: "以项目为最高层级的小红书内容生成工作台。", }; export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { diff --git a/app/openapi.json/route.ts b/app/openapi.json/route.ts index f008588..eed577e 100644 --- a/app/openapi.json/route.ts +++ b/app/openapi.json/route.ts @@ -4,16 +4,19 @@ 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 进行乐观并发控制。" }, + info: { title: "Rednote Content Studio API", version: "0.2.0", description: "小红书内容生成工作台的起步接口。项目是最高层级,每个项目保存一套共用的内容生成方法;写操作使用 revision 进行乐观并发控制。" }, servers: [{ url: origin }], security: [{ basicAuth: [] }], paths: { "/api/health": { get: { operationId: "health", summary: "无需认证的容器健康检查", security: [], responses: { "200": { description: "服务健康" } } } }, "/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: "已创建" } } }, + post: { operationId: "createProject", summary: "创建顶层内容项目", requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string" }, recipe: { $ref: "#/components/schemas/ProjectRecipe" } } } } } }, responses: { "201": { description: "已创建" } } }, + }, + "/api/projects/{id}": { + get: { operationId: "getProject", summary: "读取项目与生成方法", parameters: [{ $ref: "#/components/parameters/ProjectId" }], responses: { "200": { description: "项目详情" } } }, + put: { operationId: "updateProject", summary: "更新项目与生成方法", parameters: [{ $ref: "#/components/parameters/ProjectId" }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string" }, revision: { type: "integer" }, recipe: { $ref: "#/components/schemas/ProjectRecipe" } } } } } }, responses: { "200": { description: "已更新" }, "409": { description: "revision 冲突" } } }, }, - "/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 冲突" } } }, @@ -33,6 +36,7 @@ export function GET(request: Request) { FilePath: { name: "path", in: "path", required: true, schema: { type: "string" }, description: "项目相对路径,例如 src/App.jsx" }, }, schemas: { + ProjectRecipe: { type: "object", properties: { positioning: { type: "string" }, audience: { type: "string" }, tone: { type: "string" }, contentFormat: { type: "string" }, titleMethod: { type: "string" }, bodyStructure: { type: "string" }, defaultTags: { type: "array", items: { type: "string" } } } }, FileWrite: { type: "object", required: ["content", "revision"], properties: { content: { type: "string" }, revision: { type: "integer", description: "来自项目元数据的当前 revision" } } }, }, }, diff --git a/components/Studio.tsx b/components/Studio.tsx index 287c85d..232478c 100644 --- a/components/Studio.tsx +++ b/components/Studio.tsx @@ -1,9 +1,25 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, 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 } }; +type Recipe = { + positioning: string; + audience: string; + tone: string; + contentFormat: string; + titleMethod: string; + bodyStructure: string; + defaultTags: string[]; +}; + +type Project = { + id: string; + name: string; + description: string; + recipe: Recipe; + revision: number; + updatedAt: string; +}; async function api(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers || {}) } }); @@ -12,90 +28,116 @@ async function api(url: string, init?: RequestInit): Promise { return data; } +const emptyRecipe: Recipe = { + positioning: "", + audience: "", + tone: "真诚自然", + contentFormat: "干货教程", + titleMethod: "", + bodyStructure: "", + defaultTags: [], +}; + 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 [draft, setDraft] = useState(null); const [busy, setBusy] = useState(""); - const [message, setMessage] = useState("连接已就绪"); - const [previewKey, setPreviewKey] = useState(0); + const [message, setMessage] = useState("项目是最高层级:一套项目,对应一种稳定的内容生成方式。"); 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]); + setSelected((current) => current || data.projects[0] || null); + }, []); 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]); + useEffect(() => { setDraft(selected ? structuredClone(selected) : null); }, [selected]); 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(""); } + const data = await api<{ project: Project }>("/api/projects", { + method: "POST", + body: JSON.stringify({ name: `内容项目 ${projects.length + 1}` }), + }); + setProjects((items) => [data.project, ...items]); + setSelected(data.project); + setMessage("项目骨架已创建,请完善这类内容的统一生成方法。"); + } catch (error) { setMessage((error as Error).message); } + finally { setBusy(""); } } - async function save() { - if (!selected) return; + async function saveProject() { + if (!draft) 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(""); } + const data = await api<{ project: Project }>(`/api/projects/${draft.id}`, { + method: "PUT", + body: JSON.stringify({ ...draft, revision: draft.revision }), + }); + setSelected(data.project); + setProjects((items) => items.map((item) => item.id === data.project.id ? data.project : item)); + setMessage(`项目方法已保存 · r${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(""); } + function updateRecipe(key: keyof Recipe, value: string | string[]) { + setDraft((current) => current ? { ...current, recipe: { ...emptyRecipe, ...current.recipe, [key]: value } } : current); } return
-
RRednote Tool StudioAgent-first 小工具工作台
-
OpenAPI
+
R
Rednote Content Studio小红书内容生成器 · 起步结构
+
-
+ +
-
-
{activeFile}{selected ? selected.id : "未选择项目"}
-