feat: scaffold rednote content projects

This commit is contained in:
2026-10-04 20:06:21 +08:00
parent 46c4d06db7
commit 0690ecf050
13 changed files with 327 additions and 220 deletions
+20 -32
View File
@@ -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` 无需认证。
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+32 -59
View File
@@ -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/<project-id>/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 小工具制作器”的构建和校验模块,但新界面不再使用它们。后续实现内容生成链路时再做一次数据与代码清理,避免在起步阶段同时改动过多基础设施。
+9 -2
View File
@@ -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); }
}
+54 -55
View File
@@ -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; } }
+2 -2
View File
@@ -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 }>) {
+7 -3
View File
@@ -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" } } },
},
},
+103 -61
View File
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers || {}) } });
@@ -12,90 +28,116 @@ async function api<T>(url: string, init?: RequestInit): Promise<T> {
return data;
}
const emptyRecipe: Recipe = {
positioning: "",
audience: "",
tone: "真诚自然",
contentFormat: "干货教程",
titleMethod: "",
bodyStructure: "",
defaultTags: [],
};
export default function Studio() {
const [projects, setProjects] = useState<Project[]>([]);
const [selected, setSelected] = useState<Project | null>(null);
const [files, setFiles] = useState<string[]>([]);
const [activeFile, setActiveFile] = useState("src/App.jsx");
const [content, setContent] = useState("");
const [report, setReport] = useState<Report | null>(null);
const [draft, setDraft] = useState<Project | null>(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 <main className="studio-shell">
<header className="topbar">
<div><span className="logo">R</span><strong>Rednote Tool Studio</strong><small>Agent-first 小工具工作台</small></div>
<div className="actions"><a href="/openapi.json" target="_blank">OpenAPI</a><button onClick={createProject} disabled={Boolean(busy)}>+ 新建项目</button></div>
<div className="brand"><span className="logo">R</span><div><strong>Rednote Content Studio</strong><small>小红书内容生成器 · 起步结构</small></div></div>
<button className="primary" onClick={createProject} disabled={Boolean(busy)}>+ 新建项目</button>
</header>
<section className="workspace">
<div className="workspace">
<aside className="sidebar">
<p className="section-label">项目</p>
{projects.length === 0 && <button className="empty-project" onClick={createProject}>创建第一个小工具</button>}
{projects.map((project) => <button key={project.id} className={`project-row ${selected?.id === project.id ? "selected" : ""}`} onClick={() => { setSelected(project); setReport(null); }}><span>{project.name}</span><small>r{project.revision}</small></button>)}
{selected && <><p className="section-label files-label">文件</p>{files.map((file) => <button key={file} className={`file-row ${activeFile === file ? "selected" : ""}`} onClick={() => setActiveFile(file)}>{file}</button>)}</>}
<div className="section-head"><span>项目</span><small>最高层级</small></div>
{projects.length === 0 && <button className="empty-project" onClick={createProject}>创建第一个内容项目</button>}
{projects.map((project) => <button key={project.id} className={`project-row ${selected?.id === project.id ? "selected" : ""}`} onClick={() => setSelected(project)}>
<span>{project.name}</span><small>r{project.revision}</small>
</button>)}
<div className="sidebar-note"><b>项目是什么?</b><p>项目代表一类用同样方式生成的内容,例如“每周护肤成分拆解”或“上海周末探店”。</p></div>
</aside>
<section className="editor-panel">
<div className="panel-head"><div><strong>{activeFile}</strong><small>{selected ? selected.id : "未选择项目"}</small></div><button onClick={save} disabled={!selected || Boolean(busy)}>{busy === "save" ? "保存中…" : "保存"}</button></div>
<textarea value={content} onChange={(event) => setContent(event.target.value)} spellCheck={false} disabled={!selected} aria-label="文件编辑器" />
<div className="statusbar"><span className={report?.status === "failed" ? "error-dot" : "ok-dot"} />{message}</div>
</section>
<section className="preview-panel">
<div className="panel-head"><div><strong>手机预览</strong><small>离线 H5 产物</small></div><button onClick={() => run("build")} disabled={!selected || Boolean(busy)}>{busy === "build" ? "构建中…" : "构建并刷新"}</button></div>
<div className="phone"><div className="speaker" /><iframe src={previewUrl} title="小工具预览" /></div>
</section>
<aside className="inspect-panel">
<p className="section-label">交付门禁</p>
<div className="command-grid"><button onClick={() => run("validate")} disabled={!selected || Boolean(busy)}>检查当前产物</button><button className="primary" onClick={() => run("package")} disabled={!selected || Boolean(busy)}>生成 ZIP</button></div>
{report ? <div className={`report ${report.status}`}><strong>{report.status === "passed" ? "检查通过" : "需要修复"}</strong><small>skill {report.skillVersion} · {(report.limits.unpackedBytes / 1024).toFixed(1)} KiB</small><pre>{report.auditOutput}</pre>{report.issues.map((issue) => <article key={`${issue.code}-${issue.file}-${issue.line || 0}`}><b>{issue.code}</b><span>{issue.file}{issue.line ? `:${issue.line}` : ""}</span><p>{issue.message}</p><small>{issue.suggestion}</small></article>)}</div> : <div className="report-placeholder"><span>✓</span><p>构建后显示结构化检查结果、修复建议和 skill 审计摘要。</p></div>}
<div className="agent-note"><strong>为 Codex 设计</strong><p>通过文件 API 修改代码,用 revision 防止覆盖,再调用 build、validate 和 package。完整契约见 OpenAPI。</p></div>
</aside>
</section>
{!draft ? <section className="welcome">
<span className="welcome-mark">✦</span><h1>先创建一个项目</h1><p>为一类内容定义固定的定位、受众、语气和结构。</p><button className="primary" onClick={createProject}>创建项目</button>
</section> : <>
<section className="project-editor">
<div className="page-title"><div><span>项目配置</span><h1>{draft.name}</h1><p>{draft.description}</p></div><button className="primary" onClick={saveProject} disabled={Boolean(busy)}>{busy === "save" ? "保存中…" : "保存项目方法"}</button></div>
<div className="form-card">
<h2>基本信息</h2>
<label>项目名称<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label>
<label>项目说明<textarea rows={2} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></label>
</div>
<div className="form-card">
<div className="card-title"><div><h2>统一生成方法</h2><p>项目下的每篇内容都会继承这些规则。</p></div><span className="status-pill">结构已建立</span></div>
<div className="form-grid">
<label>内容定位<textarea rows={3} value={draft.recipe?.positioning || ""} onChange={(event) => updateRecipe("positioning", event.target.value)} placeholder="这个项目持续提供什么价值?" /></label>
<label>目标受众<textarea rows={3} value={draft.recipe?.audience || ""} onChange={(event) => updateRecipe("audience", event.target.value)} placeholder="内容主要写给谁?" /></label>
<label>表达语气<select value={draft.recipe?.tone || "真诚自然"} onChange={(event) => updateRecipe("tone", event.target.value)}><option>真诚自然</option><option>专业可信</option><option>轻松活泼</option><option>温柔治愈</option><option>犀利直接</option></select></label>
<label>内容形式<select value={draft.recipe?.contentFormat || "干货教程"} onChange={(event) => updateRecipe("contentFormat", event.target.value)}><option>干货教程</option><option>清单合集</option><option>经验故事</option><option>产品种草</option><option>测评对比</option></select></label>
<label>标题方法<textarea rows={3} value={draft.recipe?.titleMethod || ""} onChange={(event) => updateRecipe("titleMethod", event.target.value)} placeholder="标题采用什么固定方法?" /></label>
<label>正文结构<textarea rows={3} value={draft.recipe?.bodyStructure || ""} onChange={(event) => updateRecipe("bodyStructure", event.target.value)} placeholder="例如:痛点开场 → 3 个步骤 → 总结" /></label>
</div>
<label>默认标签<input value={(draft.recipe?.defaultTags || []).join(",")} onChange={(event) => updateRecipe("defaultTags", event.target.value.split(/[,,]/).map((item) => item.trim()).filter(Boolean))} placeholder="经验分享,实用干货" /></label>
</div>
</section>
<aside className="next-panel">
<span className="eyebrow">下一阶段</span><h2>内容生成区</h2><p>本次只搭建项目层和生成方法,不开始实际文案生成。</p>
<div className="future-step"><b>01</b><span><strong>输入选题简报</strong><small>主题、角度、要点和素材</small></span></div>
<div className="future-step"><b>02</b><span><strong>按项目方法生成</strong><small>标题、正文与标签</small></span></div>
<div className="future-step"><b>03</b><span><strong>保存内容草稿</strong><small>编辑、审核与导出</small></span></div>
<button disabled>生成内容(待接入)</button>
</aside>
</>}
</div>
<footer className="statusbar"><span />{message}</footer>
</main>;
}
+58 -4
View File
@@ -2,10 +2,37 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { PROJECTS_ROOT, TEMPLATES_ROOT } from "./config";
import type { ProjectMeta } from "./types";
import type { ContentProjectRecipe, ProjectMeta } from "./types";
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,48}$/;
const DEFAULT_RECIPE: ContentProjectRecipe = {
positioning: "分享真实、可复用的经验",
audience: "对这个主题感兴趣的小红书用户",
tone: "真诚自然",
contentFormat: "干货教程",
titleMethod: "突出具体收益,不夸张、不制造焦虑",
bodyStructure: "痛点开场 → 核心方法 → 分点说明 → 行动建议",
defaultTags: ["经验分享", "实用干货"],
};
function cleanText(value: unknown, fallback: string) {
return typeof value === "string" && value.trim() ? value.trim().slice(0, 2000) : fallback;
}
function normalizeRecipe(value: unknown, previous = DEFAULT_RECIPE): ContentProjectRecipe {
const input = value && typeof value === "object" ? value as Partial<ContentProjectRecipe> : {};
return {
positioning: cleanText(input.positioning, previous.positioning),
audience: cleanText(input.audience, previous.audience),
tone: cleanText(input.tone, previous.tone),
contentFormat: cleanText(input.contentFormat, previous.contentFormat),
titleMethod: cleanText(input.titleMethod, previous.titleMethod),
bodyStructure: cleanText(input.bodyStructure, previous.bodyStructure),
defaultTags: Array.isArray(input.defaultTags) ? input.defaultTags.map((item) => cleanText(item, "")).filter(Boolean).slice(0, 20) : previous.defaultTags,
};
}
export function assertProjectId(id: string) {
if (!ID_PATTERN.test(id)) throw new Error("Invalid project id");
}
@@ -51,21 +78,48 @@ export async function listProjects() {
return results.filter((item): item is ProjectMeta => Boolean(item)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function createProject(input: { name?: string; template?: string }) {
export async function createProject(input: { name?: string; description?: string; recipe?: Partial<ContentProjectRecipe>; template?: string }) {
const template = input.template || "basic";
const templateRoot = path.join(/* turbopackIgnore: true */ TEMPLATES_ROOT, template);
await fs.access(templateRoot);
const slug = (input.name || "新小工具").toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-|-$/g, "").slice(0, 28) || "tool";
const slug = (input.name || "新内容项目").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 28) || "project";
const id = `${slug}-${crypto.randomBytes(3).toString("hex")}`;
const root = projectRoot(id);
await fs.mkdir(root, { recursive: true });
await fs.cp(templateRoot, workspaceRoot(id), { recursive: true });
const now = new Date().toISOString();
const meta: ProjectMeta = { id, name: input.name || "新小工具", template, revision: 1, createdAt: now, updatedAt: now, lastAccessAt: now };
const meta: ProjectMeta = {
id,
name: cleanText(input.name, "新内容项目").slice(0, 60),
description: cleanText(input.description, "用同一套方法持续生成一类小红书内容"),
recipe: normalizeRecipe(input.recipe),
template,
revision: 1,
createdAt: now,
updatedAt: now,
lastAccessAt: now,
};
await fs.writeFile(path.join(root, "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
export async function updateProject(id: string, input: { name?: string; description?: string; recipe?: Partial<ContentProjectRecipe>; revision?: number }) {
const meta = await readMetaFile(id);
if (input.revision !== undefined && input.revision !== meta.revision) {
const error = new Error(`Revision conflict: expected ${input.revision}, current ${meta.revision}`);
(error as Error & { status?: number }).status = 409;
throw error;
}
meta.name = cleanText(input.name, meta.name).slice(0, 60);
meta.description = cleanText(input.description, meta.description);
meta.recipe = normalizeRecipe(input.recipe, meta.recipe || DEFAULT_RECIPE);
meta.revision += 1;
meta.updatedAt = new Date().toISOString();
meta.lastAccessAt = meta.updatedAt;
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
export async function listFiles(id: string) {
await touchProject(id);
const root = workspaceRoot(id);
+12
View File
@@ -1,6 +1,18 @@
export type ContentProjectRecipe = {
positioning: string;
audience: string;
tone: string;
contentFormat: string;
titleMethod: string;
bodyStructure: string;
defaultTags: string[];
};
export type ProjectMeta = {
id: string;
name: string;
description: string;
recipe: ContentProjectRecipe;
template: string;
revision: number;
createdAt: string;
+4
View File
@@ -1,3 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import test from "node:test";
import { createProject, projectRoot, updateProject } from "../lib/storage";
test("creates a top-level content project and saves its shared recipe", async (t) => {
const project = await createProject({ name: "测试内容项目" });
t.after(() => fs.rm(projectRoot(project.id), { recursive: true, force: true }));
assert.match(project.id, /^project-[a-f0-9]{6}$/);
assert.equal(project.recipe.tone, "真诚自然");
const updated = await updateProject(project.id, {
revision: project.revision,
description: "固定方式生成同类内容",
recipe: { audience: "第一次做内容的创作者", defaultTags: ["内容创作"] },
});
assert.equal(updated.revision, 2);
assert.equal(updated.description, "固定方式生成同类内容");
assert.equal(updated.recipe.audience, "第一次做内容的创作者");
assert.deepEqual(updated.recipe.defaultTags, ["内容创作"]);
assert.equal(updated.recipe.contentFormat, "干货教程");
});