Expose plain-text API documentation with agent_mode
studio CI and deployment / verify-and-deploy (push) Successful in 3m17s
wiki CI and deployment / verify-and-deploy (push) Successful in 16m44s

This commit is contained in:
2026-10-26 14:59:54 +08:00
parent b0a60ea498
commit f81aed8ce8
20 changed files with 228 additions and 5 deletions
+2
View File
@@ -3,3 +3,5 @@
Wiki、创作站和 Python 导出工具。
详见 [项目结构与运行方式](docs/monorepo.md)。
业务接口详见 [API 说明](docs/api.md),线上纯文本目录为 `/api?agent_mode=True`。
+11
View File
@@ -0,0 +1,11 @@
import type {NextApiRequest,NextApiResponse} from 'next';
const docs:Record<string,string>={
'/studio/api/export/{slug}': '# 导出创作方案\n\n方法:GET\n路径参数:slug,创作方案的稳定标识。\n查询参数:format=markdown 返回 Markdown;其他值或省略时返回带 version:1 的方案 JSON,带附件文件名。\n示例:GET /studio/api/export/{slug}\n错误:404 方案不存在;405 方法不支持。',
'/studio/api/health': '# 创作站健康检查\n\n方法:GET, HEAD\n无业务参数。GET 返回 200 JSON:{"status":"ok","service":"prompt-studio"};HEAD 无正文。\n示例:GET /studio/api/health\n错误:405 方法不支持。',
};
export function serveApiDocumentation(req:NextApiRequest,res:NextApiResponse,path:string){
if(!['GET','HEAD'].includes(req.method||'')||typeof req.query.agent_mode!=='string'||req.query.agent_mode.toLowerCase()!=='true')return false;
if(!docs[path])return false;
res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');res.status(200);
if(req.method==='HEAD')res.end();else res.send(docs[path]+'\n\n使用 GET 或 HEAD,在原地址加 ?agent_mode=True;已有参数时加 &agent_mode=True。值不区分大小写。动态路径可保留占位符读取说明。业务调用请移除 agent_mode。\nAPI 目录:/api?agent_mode=True\n');return true;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import {serveApiDocumentation} from '../../../lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
import {ideas} from '../../../content/ideas';
export default function handler(req:NextApiRequest,res:NextApiResponse){if(req.method!=='GET')return res.status(405).end();const idea=ideas.find(i=>i.slug===req.query.slug);if(!idea)return res.status(404).end();if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send('# '+idea.title+'\n\n'+idea.summary+'\n\n'+idea.slides.map(s=>'## '+s.heading+'\n\n'+s.body).join('\n\n'));}res.setHeader('Content-Disposition',`attachment; filename="${idea.slug}.json"`);return res.json({version:1,...idea});}
export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;if(req.method!=='GET')return res.status(405).end();const idea=ideas.find(i=>i.slug===req.query.slug);if(!idea)return res.status(404).end();if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send('# '+idea.title+'\n\n'+idea.summary+'\n\n'+idea.slides.map(s=>'## '+s.heading+'\n\n'+s.body).join('\n\n'));}res.setHeader('Content-Disposition',`attachment; filename="${idea.slug}.json"`);return res.json({version:1,...idea});}
+2 -1
View File
@@ -1,2 +1,3 @@
import {serveApiDocumentation} from '../../lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from "next";
export default function handler(req:NextApiRequest,res:NextApiResponse){res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});}
export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/health'))return;res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});}
+1 -1
View File
@@ -10,7 +10,7 @@
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && node scripts/prepare-standalone.mjs",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "tsx --test tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx",
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx",
"test:smoke": "node scripts/smoke.mjs",
"storage:prepare": "tsx scripts/bootstrap.ts",
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
+101
View File
@@ -0,0 +1,101 @@
import type {NextApiRequest,NextApiResponse} from 'next';
// Read documentation before business validation or storage access. Mutations never enter this branch.
export const apiDocuments:Record<string,readonly string[]>={
"/api/explore-feed": [
"统一内容流",
"GET",
"查询参数:q(可选,最多 200 字符);category(可选,标签名称或命名空间);cursor(可选,上一页 nextCursor)。",
"JSON:entries 数组与 nextCursor;没有更多结果时 nextCursor 为 null。静态内容仅出现在第一页。",
"GET /api/explore-feed?q=排版",
"503:素材库暂时无法连接。"
],
"/api/feed": [
"素材列表",
"GET",
"查询参数:section(images / videos / audio / articles / motion,可选);path(可选,标签 path);q(可选,最多 200 字符);cursor(可选,上一页 nextCursor)。",
"JSON:posts、tags、contributions、nextCursor;每页最多 24 个 post。",
"GET /api/feed?section=images",
"400:未知 section、标签或无效游标。"
],
"/api/notes/{id}": [
"读取与编辑笔记",
"GET, PUT",
"路径参数:id,笔记稳定 ID。PUT 使用 application/json,所有字段必填:title(1–200 字符)、description(最多 1000 字符)、markdown(最多 100000 字符)、tags(最多 50 个标签 UUID)、revision(GET 返回的 entry.revision,64 位十六进制)。",
"GET 返回 {entry,tags};PUT 返回更新后的 {entry}。先 GET 获取 revision,再 PUT;冲突后重新读取。",
"GET /api/notes/{id}\nPUT /api/notes/{id}\n{\"title\":\"标题\",\"description\":\"简介\",\"markdown\":\"正文\",\"tags\":[],\"revision\":\"<GET 返回的 entry.revision>\"}",
"400:输入错误;404:笔记不存在;409:版本冲突。"
],
"/api/posts": [
"创建笔记或素材包",
"POST",
"application/json:kind(必填,image / video / audio / article / motion)、title(必填,1–200 字符);slug(可选,最多 120 字符,以小写字母或数字开头,仅含小写字母、数字、连字符);description(可选,最多 1000 字符);text(可选,最多 100000 字符);tags(可选,最多 50 个 UUID);coverAssetId(可选,图片 UUID);assets(可选,最多 100 项,{id: UUID,role: content | caption},role 默认 content)。媒体类型必须引用至少一个对应类型的 content 素材。",
"201 JSON:{id,slug}。引用已有素材;不会在此接口上传二进制文件。",
"POST /api/posts\n{\"kind\":\"article\",\"title\":\"新笔记\",\"text\":\"正文\",\"tags\":[]}",
"400:输入或素材引用不合法;409:slug 已存在。"
],
"/api/tags": [
"标签列表与创建",
"GET, POST",
"POST 使用 application/json:name(必填,去掉开头 # 并 trim 后 1–60 字符,不含 #、空白、英文或中文逗号);slug(可选,最多 60 字符,以小写字母或数字开头,仅含小写字母、数字、连字符)。",
"GET 返回标签数组;POST 新建返回 201,名称已存在时复用并返回 200。标签含 id、name、slug、path。",
"POST /api/tags\n{\"name\":\"排版\"}",
"400:输入错误;409:slug 冲突。"
],
"/api/upload": [
"上传媒体并创建素材包",
"POST",
"multipart/form-data:kind(必填,image / video / audio);title(必填,1–200 字符);slug(可选,小写字母或数字组成的非空段,以连字符分隔,最多 120 字符);description(可选,最多 100000 字符);tags(可选,UUID 数组的 JSON 字符串,最多 50 项);文件字段名任意,至少一个文件。最多 16 个文件,单个不超过 250 MB,总计不超过 1 GB。图片支持 JPG、PNG、WebP、GIF、AVIF;音频必须为独立声音文件。",
"201 JSON:{id,slug,url,assets},assets 为素材 UUID 数组。视频会自动进入后台加工队列。",
"POST /api/upload\nContent-Type: multipart/form-data\nkind=image\ntitle=示例图片\ntags=[]\nfile=<图片文件>",
"400:输入或媒体不合法;409:slug 冲突;413:上传超过限制。"
],
"/api/media/{id}": [
"读取媒体原件或衍生文件",
"GET, HEAD",
"路径参数:id,素材 UUID。查询参数:variant(可选,poster / silent_video / audio_track,读取以 id 为来源的衍生素材)。支持 Range 请求头。",
"二进制媒体,Content-Type 随文件类型变化;200 为完整文件,206 为范围响应,HEAD 无正文。",
"GET /api/media/{id}?variant=poster",
"404:素材不存在或无效 variant;416:请求范围无效。"
],
"/api/preview": [
"轻量图片预览",
"GET, HEAD",
"查询参数:source(必填,本站静态栅格图片路径,或 /api/media/<UUID>,可带 ?variant=poster;作为查询值时必须 URL 编码);width(可选,240 / 480 / 960 / 1280,默认 480)。不接受任意远程图片地址。支持 If-None-Match。",
"WebP 图片;包含 ETag;缓存命中可返回 304;HEAD 无正文。",
"GET /api/preview?source=%2Fapi%2Fmedia%2F<UUID>&width=480",
"400:参数不合法;404:图片不存在;503:预览暂不可用。"
],
"/api/skills/street-people": [
"透明人物规范与上传",
"GET, POST",
"GET 默认返回 JSON 规范,format=md 返回创作指导 Markdown。POST 使用 multipart/form-data,沿用 /api/upload 字段与大小限制;kind 固定为 image,title 默认“街头小人 · 新生成”。图片必须为带 alpha 的 PNG,含 alpha=0 的透明像素及 alpha≥250 的像素。",
"GET 返回生成规范;POST 返回 201 {id,slug,url,assets}。",
"GET /api/skills/street-people\nPOST /api/skills/street-people\nContent-Type: multipart/form-data\nfile=<透明 PNG>",
"400:图片或字段不合法;413:超过上传限制。"
],
"/api/health": [
"应用健康检查",
"GET, HEAD",
"无业务参数。",
"GET 返回 JSON:status、service、release;HEAD 仅返回状态和响应头。",
"GET /api/health",
"405:不支持的方法。"
],
"/api/ready": [
"依赖就绪检查",
"GET",
"无业务参数。检查 PostgreSQL 和对象存储桶。",
"200 JSON:{\"status\":\"ok\",\"database\":\"ready\",\"objects\":\"ready\"}。",
"GET /api/ready",
"503 JSON:{\"status\":\"unavailable\"}。"
]
};
export function serveApiDocumentation(req:NextApiRequest,res:NextApiResponse,path:string){
if(!['GET','HEAD'].includes(req.method||'')||typeof req.query.agent_mode!=='string'||req.query.agent_mode.toLowerCase()!=='true')return false;
const doc=apiDocuments[path];if(!doc)return false;
const [title,methods,parameters,response,example,errors]=doc;
const text=`# ${title}\n\n地址:${path}\n方法:${methods}\n\n## 参数\n${parameters}\n\n## 响应\n${response}\n\n## 调用示例\n${example}\n\n## 错误\n${errors}\n\n## 读取说明\n使用 GET 或 HEAD,在原地址加 ?agent_mode=True;已有查询参数时加 &agent_mode=True。值不区分大小写。动态路径可使用实际 ID,也可保留占位符读取本接口说明。\n业务调用请移除 agent_mode。POST / PUT 不会被转换为说明请求。写入接口会拒绝 Origin 与 Host 不一致的请求;当前没有 API Key 或登录鉴权。\n\nAPI 目录:/api?agent_mode=True\n`;
res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');
res.status(200);if(req.method==='HEAD')res.end();else res.send(text);return true;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
import {exploreFeed} from '@/lib/explore';
export default async function handler(req:NextApiRequest,res:NextApiResponse){if(req.method!=='GET')return res.status(405).end();try{return res.json(await exploreFeed(String(req.query.q||'').slice(0,200),String(req.query.category||''),String(req.query.cursor||'')||undefined));}catch{return res.status(503).json({error:'素材库暂时无法连接'});}}
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/explore-feed'))return;if(req.method!=='GET')return res.status(405).end();try{return res.json(await exploreFeed(String(req.query.q||'').slice(0,200),String(req.query.category||''),String(req.query.cursor||'')||undefined));}catch{return res.status(503).json({error:'素材库暂时无法连接'});}}
+2
View File
@@ -1,8 +1,10 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type { NextApiRequest,NextApiResponse } from "next";
import { feed } from "@/lib/library";
import { validSection } from "@/lib/model";
import { apiError } from "@/lib/api";
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/feed'))return;
if(req.method!=='GET')return res.status(405).end();
try {const section=String(req.query.section||'');if(section&&!validSection(section))return res.status(400).json({error:'未知分类'});
res.setHeader('Cache-Control','no-store');return res.json(await feed({kind:validSection(section)?.kind,path:String(req.query.path||''),cursor:String(req.query.cursor||''),q:String(req.query.q||'').slice(0,200)}));
+2
View File
@@ -1,7 +1,9 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type { NextApiRequest, NextApiResponse } from "next";
import { healthResponse } from "@/lib/health";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if(serveApiDocumentation(req,res,'/api/health'))return;
const { statusCode, body } = healthResponse(req.method, process.env.RELEASE_SHA);
res.setHeader("Cache-Control", "no-store");
if (statusCode === 405) res.setHeader("Allow", "GET, HEAD");
+14
View File
@@ -0,0 +1,14 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {apiDocuments} from '@/lib/api-documentation';
export default function handler(req:NextApiRequest,res:NextApiResponse){
if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end();}
const entries=[...Object.entries(apiDocuments).map(([path,doc])=>({path,title:doc[0],methods:doc[1],documentation:path+'?agent_mode=True'})),
{path:'/studio/api/export/{slug}',title:'导出创作方案',methods:'GET',documentation:'/studio/api/export/{slug}?agent_mode=True'},
{path:'/studio/api/health',title:'创作站健康检查',methods:'GET, HEAD',documentation:'/studio/api/health?agent_mode=True'}];
res.setHeader('Cache-Control','no-store');
const plain=typeof req.query.agent_mode==='string'&&req.query.agent_mode.toLowerCase()==='true';
res.setHeader('Content-Type',plain?'text/plain; charset=utf-8':'application/json; charset=utf-8');
if(req.method==='HEAD')return res.status(200).end();
if(plain)return res.send('# PROMPT 业务 API\n\n各接口使用 GET + agent_mode=True 读取说明;已有参数时用 & 拼接。动态路径允许保留占位符。业务调用请移除该参数。\n\n'+entries.map(e=>`- ${e.title}(${e.methods}):${e.documentation}`).join('\n')+'\n');
return res.json({entries});
}
+2
View File
@@ -1,8 +1,10 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type { NextApiRequest,NextApiResponse } from "next";
import type { Readable } from "node:stream";
import { db } from "@/lib/db";
import { getObject } from "@/lib/storage";
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/media/{id}'))return;
if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();
if(!/^[0-9a-f-]{36}$/.test(String(req.query.id)))return res.status(404).end();
try{
+2
View File
@@ -1,9 +1,11 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
import {getEntry} from '@/lib/explore';
import {tags} from '@/lib/library';
import {noteEditInput,saveNote,NoteEditConflict} from '@/lib/note-edits';
import {sameOrigin,apiError} from '@/lib/api';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/notes/{id}'))return;
res.setHeader('Cache-Control','no-store');
if(!['GET','PUT'].includes(req.method||'')){res.setHeader('Allow','GET, PUT');return res.status(405).end();}
try{
+2
View File
@@ -1,7 +1,9 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type { NextApiRequest,NextApiResponse } from "next";
import { createPost,postInput } from "@/lib/write";
import { sameOrigin,apiError } from "@/lib/api";
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/posts'))return;
if(req.method!=='POST')return res.status(405).end();
try{sameOrigin(req);return res.status(201).json(await createPost(postInput.parse(req.body)));}catch(e){return apiError(res,e);}
}
+3 -1
View File
@@ -1,6 +1,8 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
import {db} from '@/lib/db';
import {getObject} from '@/lib/storage';
import {previewWidth,cachedPreview,loadStaticPreview} from '@/lib/preview';
export default async function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/preview'))return;if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
export const config={api:{responseLimit:false}};
+2
View File
@@ -1,8 +1,10 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type { NextApiRequest,NextApiResponse } from 'next';
import { db } from '@/lib/db';
import { storage,bucket } from '@/lib/storage';
import { HeadBucketCommand } from '@aws-sdk/client-s3';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/ready'))return;
if(req.method!=='GET')return res.status(405).end();
res.setHeader('Cache-Control','no-store');
try{await db().query('SELECT 1 FROM schema_migrations LIMIT 1');await storage().send(new HeadBucketCommand({Bucket:bucket()}));return res.json({status:'ok',database:'ready',objects:'ready'});}catch{return res.status(503).json({status:'unavailable'});}
@@ -1,3 +1,4 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from "next";
import {upload} from "../upload";
import {streetPeople} from "@/lib/street-people";
@@ -5,6 +6,7 @@ import {guidanceMarkdown} from "@/content/skills/guidance";
import {validateCharacter} from "@/lib/character-image";
export const config={api:{bodyParser:false}};
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/skills/street-people'))return;
res.setHeader("Allow","GET, POST");
if(req.method==="GET"){
res.setHeader("Cache-Control","public, max-age=300");
+2
View File
@@ -1,3 +1,4 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
import {randomUUID} from 'node:crypto';
import {z} from 'zod';
@@ -5,6 +6,7 @@ import {db} from '@/lib/db';
import {tags} from '@/lib/library';
import {sameOrigin,apiError} from '@/lib/api';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/api/tags'))return;
try{
if(req.method==='GET')return res.json(await tags());
if(req.method!=='POST')return res.status(405).end();
+2
View File
@@ -1,3 +1,4 @@
import {serveApiDocumentation} from '@/lib/api-documentation';
import type { NextApiRequest,NextApiResponse } from "next";
import formidable,{type File} from "formidable";
import { randomUUID,createHash } from "node:crypto";
@@ -12,6 +13,7 @@ import { sameOrigin,apiError } from "@/lib/api";
export const config={api:{bodyParser:false}};
export type UploadPolicy = { validateImage?: (path:string) => Promise<void>; defaultTitle?:string };
export async function upload(req:NextApiRequest,res:NextApiResponse,policy:UploadPolicy={}){
if(serveApiDocumentation(req,res,'/api/upload'))return;
if(req.method!=='POST')return res.status(405).end();
const temporary:string[]=[];const uploaded:string[]=[];
try{
+55
View File
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import type {NextApiRequest,NextApiResponse} from 'next';
import {apiDocuments,serveApiDocumentation} from '../src/lib/api-documentation';
import index from '../src/pages/api/index';
function response(){
const state={status:0,headers:{} as Record<string,string>,body:undefined as unknown};
const res={setHeader(name:string,value:string){state.headers[name]=value;return res;},status(code:number){state.status=code;return res;},send(body:unknown){state.body=body;return res;},json(body:unknown){state.body=body;return res;},end(){return res;}};
return {state,res:res as unknown as NextApiResponse};
}
const request=(method:string,agent_mode:unknown)=>({method,query:{agent_mode}} as unknown as NextApiRequest);
test('every business handler serves documentation before validating parameters or accessing dependencies',async()=>{
const routes:Record<string,string>={
'/api/explore-feed':'explore-feed','/api/feed':'feed','/api/notes/{id}':'notes/[id]',
'/api/posts':'posts/index','/api/tags':'tags','/api/upload':'upload',
'/api/media/{id}':'media/[id]','/api/preview':'preview',
'/api/skills/street-people':'skills/street-people','/api/health':'health','/api/ready':'ready',
};
for(const [path,route] of Object.entries(routes)){
const {default:handler}=await import('../src/pages/api/'+route+'.ts');
for(const method of ['GET','HEAD']){
const {state,res}=response();
await handler(request(method,'tRuE'),res);
assert.equal(state.status,200,path);
assert.equal(state.headers['Content-Type'],'text/plain; charset=utf-8',path);
if(method==='HEAD')assert.equal(state.body,undefined);else {
assert.ok(String(state.body).includes(path),path);
assert.ok(String(state.body).includes('参数'),path);
}
}
}
assert.deepEqual(Object.keys(routes).sort(),Object.keys(apiDocuments).sort());
});
test('normal calls and mutations do not enter documentation mode',()=>{
for(const [method,value] of [['GET',undefined],['GET','false'],['GET',['True','false']],['POST','True'],['PUT','True'],['DELETE','True']]){
const {state,res}=response();
assert.equal(serveApiDocumentation(request(method as string,value),res,'/api/posts'),false);
assert.equal(state.status,0);
}
});
test('index exposes every business endpoint and its text URL',()=>{
const {state,res}=response();index(request('GET','True'),res);
for(const path of [...Object.keys(apiDocuments),'/studio/api/export/{slug}','/studio/api/health'])assert.ok(String(state.body).includes(path+'?agent_mode=True'));
});
test('health keeps its regular JSON response',async()=>{
const {default:handler}=await import('../src/pages/api/health');
const {state,res}=response();handler(request('GET',undefined),res);
assert.equal(state.status,200);assert.equal((state.body as {status:string}).status,'ok');
});
+16
View File
@@ -0,0 +1,16 @@
# 业务 API 说明
业务 API 目录:`/api`(JSON)或 `/api?agent_mode=True`(纯文本)。
每个业务接口都使用原路径加 `agent_mode=True` 返回 `text/plain; charset=utf-8` 的接口说明,包含用途、方法、参数、响应、调用示例和错误。查询值不区分大小写;已有查询参数时使用 `&agent_mode=True`。使用 GET 或 HEAD 读取说明,HEAD 不含正文;说明不查询数据库或对象存储,不要求动态 ID 实际存在。
例如:
- `/api/upload?agent_mode=True`
- `/api/notes/{id}?agent_mode=True`
- `/api/preview?source=%2Fexample.png&agent_mode=True`
- `/studio/api/export/{slug}?agent_mode=True`
业务调用时移除该参数。POST、PUT 等写入方法保持原有行为,不会因该参数变成文档请求。页面正文和 Markdown 兼容接口继续使用原有规则。
Wiki 文档注册表位于 `apps/wiki/src/lib/api-documentation.ts`,由各业务路由在 handler 开头调用;目录从同一注册表生成。Studio 文档位于 `apps/studio/src/lib/api-documentation.ts`,随创作站独立部署。