This commit is contained in:
2026-05-05 14:40:41 +08:00
parent e847fcea6f
commit 07e777fef8
76 changed files with 4246 additions and 444 deletions
@@ -0,0 +1,91 @@
---
name: gpt-image-2-apimart
description: Generate or inspect project image assets through this repository's APIMart OpenAI-compatible gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug APIMART_BASE_URL / APIMART_API_KEY image-generation configuration without exposing secrets.
---
# gpt-image-2 APIMart
Use this skill for project-local image asset generation that must match the repository's `server-rs` APIMart `gpt-image-2` path.
## Workflow
1. Read the local task and decide whether the image is project-bound.
2. Prefer `scripts/generate-template-samples.mjs` for puzzle template thumbnails or small batches.
3. Run dry-run first:
```powershell
node .codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs --dry-run
```
4. If dry-run looks correct and the user asked for real assets, run live generation with a small limit:
```powershell
node .codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs --live --limit 6
```
5. Save final project assets under `public/` or another explicitly requested workspace path.
6. Never print `APIMART_API_KEY`. Report only whether configuration exists.
## Request Contract
The repository image path uses:
```text
POST {APIMART_BASE_URL}/images/generations
Authorization: Bearer {APIMART_API_KEY}
Content-Type: application/json
```
Default body:
```json
{
"model": "gpt-image-2",
"prompt": "<prompt>",
"n": 1,
"size": "1:1"
}
```
For a reference image, add:
```json
{
"image_urls": ["data:image/png;base64,..."]
}
```
Poll async responses with:
```text
GET {APIMART_BASE_URL}/tasks/{task_id}
```
Accept image output from `data[].url`, `data[].b64_json`, direct nested `url` fields, or async task results.
## Environment
Load environment values from process env first, then `.env.secrets.local`, `.env.local`, and `.env.example`.
Required for live generation:
- `APIMART_BASE_URL`
- `APIMART_API_KEY`
Optional:
- `APIMART_IMAGE_REQUEST_TIMEOUT_MS`
If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat.
## Prompt Rules
- Use Chinese prompts when generating project puzzle templates.
- Keep template samples square, clear, image-only, and suitable for puzzle thumbnails.
- Avoid text, watermark, UI chrome, buttons, borders, and tutorial overlays.
- Include local negative constraints in the prompt instead of relying on provider-specific negative prompt fields.
## Resources
- `scripts/generate-template-samples.mjs`: dry-run or live-generate puzzle template sample thumbnails.
- `assets/puzzle-template-prompts.json`: default prompt list consumed by the script.
@@ -0,0 +1,7 @@
interface:
display_name: "GPT Image 2 APIMart"
short_description: "Generate project thumbnails through APIMart"
brand_color: "#10B981"
default_prompt: "Use $gpt-image-2-apimart to dry-run or generate puzzle template thumbnails through APIMart."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,62 @@
[
{
"id": "couple-memory",
"title": "情侣合照拼图",
"prompt": "温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确,适合切成拼图。"
},
{
"id": "family-keepsake",
"title": "家庭纪念拼图",
"prompt": "三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。"
},
{
"id": "friends-party",
"title": "朋友聚会拼图",
"prompt": "朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹,适合社交分享拼图。"
},
{
"id": "festival-card",
"title": "节日贺卡拼图",
"prompt": "节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨,适合节日拼图。"
},
{
"id": "knowledge-summary",
"title": "知识总结拼图",
"prompt": "一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确,适合学习打卡拼图。"
},
{
"id": "product-detail",
"title": "商品细节拼图",
"prompt": "精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净,适合作为电商细节拼图。"
},
{
"id": "healing-landscape",
"title": "治愈风景拼图",
"prompt": "治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和,层次清楚,局部元素可辨,适合长时间拼图。"
},
{
"id": "cute-pet",
"title": "宠物可爱拼图",
"prompt": "一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。"
},
{
"id": "hot-topic-poster",
"title": "热点海报拼图",
"prompt": "电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字,适合热点话题拼图。"
},
{
"id": "event-invitation",
"title": "活动邀请拼图",
"prompt": "活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字,适合活动预热拼图。"
},
{
"id": "daily-challenge",
"title": "每日挑战拼图",
"prompt": "每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解,适合平台每日拼图。"
},
{
"id": "children-learning",
"title": "儿童认知拼图",
"prompt": "儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字,适合儿童教育拼图。"
}
]
@@ -0,0 +1,347 @@
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const skillRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
const promptsPath = path.join(
skillRoot,
'assets',
'puzzle-template-prompts.json',
);
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
const defaultTimeoutMs = 180000;
const pollDelayMs = 3000;
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const raw = process.argv[index];
if (raw.startsWith('--')) {
const next = process.argv[index + 1];
if (next && !next.startsWith('--')) {
args.set(raw, next);
index += 1;
} else {
args.set(raw, true);
}
}
}
function readDotenv(fileName) {
const filePath = path.join(repoRoot, fileName);
if (!existsSync(filePath)) {
return {};
}
const values = {};
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
if (!match) {
continue;
}
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return values;
}
function resolveEnv() {
const loaded = {
...readDotenv('.env.example'),
...readDotenv('.env.local'),
...readDotenv('.env.secrets.local'),
...process.env,
};
return {
baseUrl: String(loaded.APIMART_BASE_URL || '').trim().replace(/\/+$/u, ''),
apiKey: String(loaded.APIMART_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.APIMART_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
}
function buildPrompt(template) {
return [
'请生成一张高清 1:1 方形插画,用作拼图创作模板样例图。',
`画面主体:${template.prompt}`,
'要求:主体清晰集中,前中后景层次明确,边角有可辨识细节,适合切成 3x3 到 7x7 拼图。',
'避免:文字、水印、边框、按钮、UI 元素、教程标注、低清晰度、过度模糊、杂乱构图。',
].join('');
}
function collectStringsByKey(value, targetKey, output) {
if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
return;
}
if (!value || typeof value !== 'object') {
return;
}
for (const [key, nested] of Object.entries(value)) {
if (key === targetKey) {
if (typeof nested === 'string' && nested.trim()) {
output.push(nested.trim());
}
if (Array.isArray(nested)) {
nested.forEach((entry) => {
if (typeof entry === 'string' && entry.trim()) {
output.push(entry.trim());
}
});
}
}
collectStringsByKey(nested, targetKey, output);
}
}
function extractImageUrls(payload) {
const urls = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
}
function extractBase64Images(payload) {
const values = [];
collectStringsByKey(payload, 'b64_json', values);
return values;
}
function extractTaskId(payload) {
const ids = [];
collectStringsByKey(payload, 'task_id', ids);
collectStringsByKey(payload, 'taskId', ids);
collectStringsByKey(payload, 'id', ids);
return ids[0] || null;
}
function inferExtensionFromContentType(contentType) {
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
if (normalized === 'image/png') {
return 'png';
}
if (normalized === 'image/webp') {
return 'webp';
}
if (normalized === 'image/gif') {
return 'gif';
}
return 'jpg';
}
function inferExtensionFromBytes(bytes) {
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
return 'png';
}
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
return 'jpg';
}
if (
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'webp';
}
return 'png';
}
async function fetchJson(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: abortController.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`APIMart ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} finally {
clearTimeout(timer);
}
}
async function downloadUrl(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`download ${response.status}`);
}
const bytes = Buffer.from(await response.arrayBuffer());
return {
bytes,
extension: inferExtensionFromContentType(
response.headers.get('content-type') || 'image/jpeg',
),
};
} finally {
clearTimeout(timer);
}
}
async function waitForTask(env, taskId) {
const deadline = Date.now() + env.timeoutMs;
await new Promise((resolve) => setTimeout(resolve, 10000));
while (Date.now() < deadline) {
const payload = await fetchJson(
`${env.baseUrl}/tasks/${encodeURIComponent(taskId)}`,
{
headers: {
Authorization: `Bearer ${env.apiKey}`,
},
},
env.timeoutMs,
);
const statuses = [];
collectStringsByKey(payload, 'status', statuses);
collectStringsByKey(payload, 'task_status', statuses);
const status = String(statuses[0] || '').trim().toLowerCase();
if (['completed', 'succeeded', 'success'].includes(status)) {
return payload;
}
if (['failed', 'error', 'canceled', 'cancelled', 'unknown'].includes(status)) {
throw new Error(`APIMart task ${taskId} failed: ${JSON.stringify(payload).slice(0, 600)}`);
}
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
}
throw new Error(`APIMart task ${taskId} timed out`);
}
async function generateOne(env, template, outDir) {
const requestBody = {
model: 'gpt-image-2',
prompt: buildPrompt(template),
n: 1,
size: '1:1',
};
const payload = await fetchJson(
`${env.baseUrl}/images/generations`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const resolvedPayload =
extractImageUrls(payload).length || extractBase64Images(payload).length
? payload
: await waitForTask(env, extractTaskId(payload));
const urls = extractImageUrls(resolvedPayload);
const b64Images = extractBase64Images(resolvedPayload);
let image;
if (urls[0]) {
image = await downloadUrl(urls[0], env.timeoutMs);
} else if (b64Images[0]) {
const bytes = Buffer.from(b64Images[0], 'base64');
image = {
bytes,
extension: inferExtensionFromBytes(bytes),
};
} else {
throw new Error(`APIMart returned no image for ${template.id}`);
}
mkdirSync(outDir, { recursive: true });
const outputPath = path.join(outDir, `${template.id}.${image.extension}`);
writeFileSync(outputPath, image.bytes);
return outputPath;
}
const dryRun = args.has('--dry-run') || !args.has('--live');
const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir));
const limit = Number.parseInt(String(args.get('--limit') || '0'), 10);
const onlyIds = String(args.get('--only') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const templates = JSON.parse(readFileSync(promptsPath, 'utf8')).filter(
(template) => !onlyIds.length || onlyIds.includes(template.id),
);
const selectedTemplates = limit > 0 ? templates.slice(0, limit) : templates;
if (dryRun) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
outDir,
count: selectedTemplates.length,
requests: selectedTemplates.map((template) => ({
id: template.id,
title: template.title,
body: {
model: 'gpt-image-2',
prompt: buildPrompt(template),
n: 1,
size: '1:1',
},
})),
},
null,
2,
),
);
process.exit(0);
}
const env = resolveEnv();
if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing APIMART_BASE_URL or APIMART_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
);
process.exit(1);
}
const generated = [];
for (const template of selectedTemplates) {
console.log(`Generating ${template.id}...`);
generated.push(await generateOne(env, template, outDir));
}
console.log(
JSON.stringify(
{
ok: true,
count: generated.length,
files: generated,
},
null,
2,
),
);
@@ -85,6 +85,11 @@
2. 用户返回成本更低
3. 操作像手游副面板,更符合预期
### 4.3.1 弹出确认面板不能透明
- 删除作品、发布后分享、确认离开等关键弹窗必须有实体面板底色,不能只靠透明背景、毛玻璃或遮罩承载内容。
- 通过 portal 挂到 `body` 的平台弹窗必须在遮罩层补齐平台主题类,否则主题变量会脱离页面容器,轻则颜色漂移,重则面板背景看起来透明。
- 移动端关键确认弹窗优先居中显示,并保留 `max-height + 内部滚动`,避免被底部导航、安全区或底部抽屉布局遮住。
### 4.4 图标优于文字按钮
- 在底部工具区,队伍/背包改成 icon 后更紧凑。
- 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。
@@ -1,5 +1,7 @@
# 抓大鹅创作入口开放与错误隔离 2026-05-01
> 2026-05-03 更新:抓大鹅创作端入口已按运营节奏暂时下线,`match3d.visible` 调整为 `false`。本文保留为 2026-05-01 入口开放阶段的历史记录;当前入口状态以 `NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 和 `src/config/newWorkEntryConfig.ts` 为准。
## 1. 背景
抓大鹅 Match3D 玩法域已完成当前 demo 主链接入,本轮恢复创作页入口,使玩家可以从创作中心直接进入抓大鹅共创工作台。同时,平台首页会并行读取 RPG、拼图、抓大鹅等公开广场数据,公开广场接口未就绪、空表或临时失败不应污染创作入口错误态,也不应表现成登录异常。
@@ -1,5 +1,7 @@
# 抓大鹅 Match3D F1 创作入口与 Agent UI 落地记录 2026-04-30
> 2026-05-03 更新:抓大鹅创作端入口已暂时下线,当前 `match3d.visible` 为 `false`。本文件记录 F1 接入能力,入口是否展示以 `NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 和 `src/config/newWorkEntryConfig.ts` 为准。
## 1. 阶段边界
本文件承接《MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md》的 F1 包。
@@ -18,10 +18,10 @@
| 玩法 | 展示 | 开放 | 说明 |
| --- | --- | --- | --- |
| 角色扮演 | | 是 | 点击后进入 RPG Agent 共创工作台 |
| 角色扮演 | | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 |
| 抓大鹅 | | 是 | 点击后进入抓大鹅 Agent 共创工作台 |
| 抓大鹅 | | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 |
@@ -20,7 +20,7 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
- 前端只负责展示生成进度与触发已有后端动作,不新增 server-node 或 PostgreSQL 链路。
- 后端继续沿用 `server-rs` + `SpacetimeDB` 的会话、草稿与资产写入能力。
- 拼图生成草稿链路仍包含:结果页草稿、候选图生成、正式图确认
- 拼图生成草稿链路仍包含:首关草稿编译、首关画面生成、正式草稿写入
- 大鱼吃小鱼生成草稿链路只包含:玩法草稿、等级蓝图、背景蓝图与运行参数编译。
- 大鱼吃小鱼的主图、动作、背景都在结果页工坊单独触发,不再属于草稿编译阶段。
- 生成过程中展示的“角色描述、角色图片、动作”等,统一映射为锚点、草稿蓝图与资产步骤,不把规则说明类文本写成默认 UI 文案。
@@ -38,9 +38,9 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
### 拼图
- `compile_puzzle_draft`:在 `server-rs`整理主题、主体、构图与标签,写入结果页草稿。
- `compile_puzzle_draft`:同一次后端 action 内根据草稿摘要生成候选图
- `compile_puzzle_draft`:同一次后端 action 内自动选择第一张候选图作为正式图
- `compile_puzzle_draft`:在 `server-rs`根据入口画面描述生成首关名称和结果页草稿。
- `compile_puzzle_draft`:同一次后端 action 内根据画面描述、参考图和当前图片模型生成首关画面
- `compile_puzzle_draft`:同一次后端 action 内自动把首图设为正式图,并同步到结果页草稿
- `ready`:进入拼图结果页。
### 大鱼吃小鱼
@@ -4,8 +4,18 @@
拼图创作入口不再使用 Agent 对话收集题材锚点。新流程让玩家填写作品名称、作品描述、画面描述三类信息,其中画面描述只服务首关画面生成与关卡画面语义,不再作为作品详情页的作品描述。画面描述支持上传参考图。玩家确认后直接进入草稿生成进度页,后续草稿生成、首图生成、正式图选择、结果页编辑和发布沿用现有后端编排。
2026-05-03 后入口进一步收口为画面描述直创:入口表单只保留画面描述、参考图和图片模型选择;作品名称、作品描述、作品标签全部进入结果页补全。若本文件早期段落仍提到入口必填作品名称或作品描述,以 `PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md` 为准。
## 入口表单
### 2026-05-03 画面描述直创补充
1. 入口表单只展示 `画面描述`、参考图和图片模型选择;`画面描述` 是唯一必填字段。
2. 表单自动保存只保存 `pictureDescription`,不再保存入口作品名称、作品描述或推断标签。
3. 点击“生成草稿”后进入生成进度页,步骤固定为“编译首关草稿 -> 生成首关画面 -> 写入正式草稿”。
4. 生成进度页“当前拼图信息”只展示画面描述;不得展示空作品名称、空作品描述或旧五锚点结构。
5. 结果页打开后,作品名称默认使用首关名称,作品描述与作品标签保持为空,等待用户在作品信息 Tab 补全或触发 AI 标签生成。
### 2026-04-30 初始表单草稿保存补充
1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。
@@ -0,0 +1,67 @@
# 拼图画面描述直创与 AI 标签生成调整 2026-05-03
## 背景
拼图创作入口继续保留填表式体验,但入口表单不再要求百梦主提前填写作品名称和作品描述。入口只收集“拼图画面描述”,后端用该描述完成首图生成和第一关关卡名生成;进入结果页后再补作品信息。
## 入口表单
1. 点击“开始创作”后的拼图表单只展示 `画面描述`、参考图和图片模型选择。
2. `画面描述` 是唯一必填字段,提交时写入 `pictureDescription`,并作为 `promptText` 传给 `compile_puzzle_draft`
3. `workTitle``workDescription` 不再从入口表单传入;`seedText` 只由画面描述组成,格式为 `画面描述:...`
4. 表单自动保存只保存画面描述,不生成图片,不消耗光点。
5. 生成进度页“当前拼图信息”只展示画面描述,不再展示空作品名称或空作品描述。
## 生成进度步骤
1. `compile` 展示为“编译首关草稿”:根据画面描述生成首关名称和结果页草稿,不在本步骤生成作品标签。
2. `puzzle-images` 展示为“生成首关画面”:按画面描述、参考图和当前图片模型生成第一张拼图图。
3. `puzzle-select-image` 展示为“写入正式草稿”:把首图设为第一关正式图,并同步到结果页草稿。
4. `ready` 文案提示进入结果页补作品信息;不得暗示作品名称、作品描述或作品标签已经完整生成。
## 草稿默认值
1. 后端先由 `module-puzzle` 生成可回滚的确定性草稿,再由 `api-server` 基于画面描述调用文本模型生成第一关关卡名;模型不可用或返回非法时才降级到确定性兜底名。
2. 第一关关卡名生成后,必须写回首关 `levelName`,并在入口直创默认场景下作为 `workTitle` 同步写入草稿和作品草稿卡。
3. `workDescription` 默认保持空字符串,不再回退为画面描述。
4. `themeTags` 默认保持空数组,不再由入口画面描述自动推断为正式作品标签。
5. `formDraft` 只保留 `pictureDescription``workTitle``workDescription` 为空。
## 作品标签
1. 作品信息 Tab 继续支持手动新增、删除标签。
2. 作品标签合法数量仍为 `3~6` 个,发布前和后端发布逻辑都要检查。
3. 新增 `generate_puzzle_tags` action
- 前端点击 AI 生成标签时先检查作品名称和作品描述。
- 若任一为空,前端直接提示先填写,不请求后端。
- 两者都不为空时,后端基于作品名称和作品描述调用文本模型,生成 6 个中文短标签。
- 生成结果回写 session draft 与 puzzle work profile,前端直接使用返回 session 更新界面。
4. AI 标签生成失败时可以降级为确定性关键词标签,但仍必须返回去重后的 6 个标签,保证用户能继续编辑。
## 保存与发布
1. 用户在结果页修改作品名称、作品描述、作品标签、关卡名称或画面描述时,继续通过 `PUT /api/runtime/puzzle/works/{profileId}` 自动保存。
2. 自动保存允许标签为空,用于支持初始草稿和用户清空标签后的继续编辑。
3. 发布前必须检查:
- 每个关卡名称非空。
- 作品名称非空。
- 作品描述非空。
- 作品标签数量为 `3~6`
- 每关正式图存在。
4. `publish_puzzle_work` 仍由 SpacetimeDB procedure 执行最终校验和发布,前端不能绕过后端门禁。
## 结果页返回
1. 从拼图草稿结果页点击左上角返回时,直接回到平台创作页。
2. 结果页返回不回到上一页填表工作区;表单页只作为发起新草稿或恢复纯表单草稿的入口。
3. 返回创作页时清理拼图生成态、运行态和临时操作态,保留后端已保存的草稿,用户后续从作品卡继续完善。
## 验收
1. 拼图入口表单不再出现作品名称和作品描述输入框。
2. 只填写画面描述即可生成草稿、图片和第一关关卡名。
3. 进入结果页后作品名称默认为模型生成的第一关关卡名,作品描述为空,作品标签为空。
4. 点击 AI 生成标签时,作品名称或作品描述为空会先提示补齐。
5. 作品名称和作品描述都不为空时,AI 生成 6 个作品标签,并自动保存到后端。
6. 手动增删标签仍可用,发布前标签必须至少 3 个且最多 6 个。
7. 拼图草稿结果页左上角返回直接回到创作页,不再显示上一页表单。
@@ -0,0 +1,87 @@
# 拼图创作模板表单与 gpt-image-2 Skill 封装 2026-05-03
## 背景
拼图创作入口已经从对话式 Agent 收口为填表式表单。本次改版目标是让“点击拼图创作”后的表单更接近图像创作工具的单屏体验:先选创作模板,再补充提示词,最后直接生成首关草稿与首张拼图图。
## 落地范围
1. `src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`
- 改为顶部标题、模板横滑区、大输入框、底部操作区的布局。
- 保留参考图上传、模型切换和生成草稿。
- 不再提供输入框底部的 `try` 示例入口。
2. `src/components/puzzle-agent/puzzleCreationTemplates.ts`
- 新增拼图创作模板数据。
- 模板来源按社交、热点、职场学习、电商、治愈、营销、儿童教育等场景抽样。
- 点击模板后把模板提示词写入画面描述。
3. `public/puzzle-creation-templates/`
- 存放模板样例图。
- 样例图只用于创作模板缩略图,不作为正式拼图作品资产。
4. `.codex/skills/gpt-image-2-apimart/`
- 封装仓库内 `gpt-image-2` 的 APIMart OpenAI 兼容调用流程。
- Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。
## UI 规则
1. 顶部只展示“创建拼图”和轻量状态标识,不写玩法规则说明。
2. 模板区横向滚动,移动端优先;每个模板卡包含样例图、短标题和选中态。
3. 点击模板时:
- 立即选中该模板。
- 如果输入框为空,直接填入模板提示词。
- 如果输入框已有内容,替换为该模板提示词,避免追加后变得冗长。
4. 输入区保留:
- 参考图上传按钮。
- 图片模型切换按钮。
5. 输入区不保留:
- `try` 文本。
- 示例 prompt chip。
- 玩法规则说明。
## 模板抽样
首批模板不追求覆盖图二所有条目,而是选择高频且适合拼图主图的代表项:
1. 情侣合照拼图
2. 家庭纪念拼图
3. 朋友聚会拼图
4. 节日贺卡拼图
5. 知识点总结拼图
6. 商品细节拼图
7. 治愈风景拼图
8. 宠物可爱拼图
9. 热点海报拼图
10. 活动邀请拼图
11. 每日挑战拼图
12. 儿童认知拼图
模板提示词必须是可直接送入拼图生图链路的画面描述,不写 UI、按钮、教程、规则或营销解释。
## gpt-image-2 Skill 规则
Skill 封装仓库现有后端口径:
```text
POST {APIMART_BASE_URL}/images/generations
Authorization: Bearer {APIMART_API_KEY}
model = gpt-image-2
size = 1:1
n = 1
```
响应兼容:
1. `data[].url`
2. `data[].b64_json`
3. `task_id` 后续 `GET /tasks/{task_id}`
本次 Skill 只封装生成样例图和研发复用流程,不改变正式后端接口、扣费、OSS、SpacetimeDB 写入和发布链路。
## 验收
1. 点击拼图创作后,表单首屏呈现模板横滑区和大输入框。
2. 点击任一模板后,输入框填入该模板提示词。
3. 输入框里没有 `try` 示例功能。
4. 图片模型切换仍可打开并选择 `gpt-image-2` / `nanobanana2`
5. 模板样例图文件存在,并能在创作表单缩略图中显示。
6. gpt-image-2 Skill 校验通过,且脚本 dry-run 能输出计划请求而不泄露密钥。
7. `npm run check:encoding` 通过。
@@ -0,0 +1,43 @@
# RPG 聊天退出后继续冒险过场方案(2026-05-03)
## 1. 目标
玩家退出 NPC 聊天后点击“继续冒险”,不能直接瞬间切到下一幕或下一场景。继续冒险必须先完成一段清晰的角色退场与入场演出,再让新对面角色主动开启对话。
## 2. 时序约束
点击“继续冒险”后的顺序固定为:
1. 保持旧场景画面,隐藏当前场景对面的所有角色。
2. 主角色与同行角色播放行走动画,向右走出屏幕。
3. 点击后可以先更新真实 `gameState/currentStory`,但画布继续使用过场模型缓存的旧可见态;退场完成前不得把新幕画面展示出来。
4. 新场景或新幕画面展示后,主角色从左侧走到默认站位。
5. 新场景对面角色从屏幕左侧走入到指定对面站位。
6. 入场完成后,如果后续选项里存在 `npc_preview_talk``npc_chat`,自动执行该选项,直接开启主角色与对面角色的对话。
## 3. 代码落点
1. `src/hooks/rpg-runtime-story/choiceActions.ts`
- 点击 `story_continue_adventure` 时只提交延迟状态与选项,不直接进入对话。
- 若延迟故事标记了自动执行,则把目标 option 放到新的 `deferredAutoChoice`
2. `src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts`
- `story_continue_adventure` 也纳入 `content-change` 过场。
- 入场动画结束后触发 `deferredAutoChoice`,避免在角色尚未走到位前开聊。
- 自动触发时通过最新回调读取当前运行态,避免计时器拿到点击“继续冒险”前的旧状态。
3. `src/components/game-canvas/GameCanvasEntityLayer.tsx`
- 退场期隐藏旧对面角色。
- 入场期让新对面角色从左侧走入到右侧指定站位。
- 对面角色入场期使用移动动画,完成后恢复 idle 与对话气泡。
4. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts`
- `story_continue_adventure` 只要携带 `deferredRuntimeState``deferredAutoChoice`,就先进入过场,再交给 story choice 处理真实状态。
## 4. 验收标准
1. 退出 NPC 聊天后点击“继续冒险”,不会在同一帧瞬间切换到下一幕对话。
2. 退场时旧对面角色不可见,主角色向右走出画面。
3. 入场时新对面角色从左侧进入右侧站位。
4. 入场完成后自动进入新对面角色对话。
5. 移动端与桌面端都不新增说明类 UI 文案,只保留游戏内演出。
@@ -0,0 +1,171 @@
# RPG 世界草稿开局 CG 手动生成技术方案(2026-05-03
## 1. 背景与本次口径
本方案落地“RPG 游戏开场 CG”第一版。它继承 `docs/prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md` 的资产化方向,但本次不采用旧 PRD 中“4 张关键帧 + 3 段视频拼接”的方案,而采用更短的两阶段链路:
```text
世界草稿
-> GPT Image 2 生成 3*4 故事板图,2k16:9
-> Seedance 使用故事板作为参考图生成单段 15 秒视频,480p,16:9
-> OSS 保存故事板与成片
-> 前端把 openingCg 回写到当前世界草稿 profile
```
本次明确不在生成世界草稿时自动生成开局 CG。入口只放在世界草稿结果页的世界 Tab,由用户手动触发。
## 2. 用户体验
1. 世界 Tab 展示一个轻量的“开局 CG”资产槽。
2. 未生成时只提供手动生成按钮。
3. 生成中展示阶段状态和进度,不把规则说明长文写进 UI。
4. 生成成功后展示视频预览和重新生成按钮。
5. 每次点击生成扣 `80` 积分,失败自动退款。
6. UI 预计等待文案为 `预计 10 分钟`,真实等待由后端同步请求完成后返回。
7. 只在世界草稿中手动生成;世界底稿、角色图、幕背景图自动补齐流程不生成开局 CG。
## 3. 数据结构
`CustomWorldProfile` 新增可选字段:
```ts
type CustomWorldOpeningCgStatus =
| 'not_started'
| 'storyboard_generating'
| 'video_generating'
| 'ready'
| 'failed';
type CustomWorldOpeningCgProfile = {
id: string;
status: CustomWorldOpeningCgStatus;
storyboardImageSrc?: string | null;
storyboardAssetId?: string | null;
videoSrc?: string | null;
videoAssetId?: string | null;
posterImageSrc?: string | null;
posterAssetId?: string | null;
storyboardPrompt?: string | null;
videoPrompt?: string | null;
imageModel: 'gpt-image-2';
videoModel: string;
aspectRatio: '16:9';
imageSize: '2k';
videoResolution: '480p';
durationSeconds: 15;
pointCost: 80;
estimatedWaitMinutes: 10;
generatedAt?: string | null;
updatedAt: string;
errorMessage?: string | null;
};
```
该字段保存在现有 profile JSON 内,不新增 SpacetimeDB 表字段。发布与保存沿用当前 `profile_payload_json` 整包存储能力。
## 4. 后端接口
新增接口:
```text
POST /api/runtime/custom-world/opening-cg
```
请求:
```ts
type GenerateCustomWorldOpeningCgRequest = {
profile: CustomWorldProfile;
};
```
响应:
```ts
type GenerateCustomWorldOpeningCgResponse = {
openingCg: CustomWorldOpeningCgProfile;
};
```
接口职责:
1. 校验登录态与 profile 基本结构。
2. 校验至少存在可扮演角色、世界基调、世界概述、核心冲突和首个场景第一幕背景图。
3. 使用 `execute_billable_asset_operation_with_cost(..., 80, ...)` 做预扣和失败退款。
4. 生成故事板图片并持久化为 `custom_world_opening_cg_storyboard` 资产。
5. 使用故事板图作为 Seedance 参考图生成视频并持久化为 `custom_world_opening_cg_video` 资产。
6. 返回可直接合并进 profile 的 `openingCg`
## 5. 提示词
### 5.1 故事板
图片模型固定使用 `gpt-image-2`,尺寸语义为 `2k``16:9`,当前 APIMart/OpenAI 兼容入口用 `2048x1152` 作为下游 size。
模板:
```text
以3*4网格格式创建故事板,16:9。像素风角色扮演游戏开场动画CG。
故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景
故事基调:{世界草稿.tone}
玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介
故事背景:{世界草稿.summary}
核心冲突:{世界草稿.coreConflicts}
开局场景:将首个场景的第一幕背景图作为参考图
```
参考图:
1. 玩家扮演角色使用第一个可扮演角色的 `imageSrc`
2. 开局场景使用 `sceneChapterBlueprints[0].acts[0].backgroundImageSrc`
3. 若缺少任一参考图,返回可理解错误,不降级到无参考图生成。
### 5.2 视频
视频模型复用当前 Ark Seedance 配置,分辨率 `480p`,比例 `16:9`,时长 `15` 秒。
提示词固定:
```text
利用参考图作为故事板,生成一段连贯的动画,没有旁白
```
请求参数必须开启生成音频与联网搜索。若当前上游字段名存在差异,后端在 Ark 请求体中以 `audio` / `generate_audio` / `web_search` 的兼容布尔字段表达,保证不会影响现有角色动画接口。
开局 CG 视频链路的上游等待窗口不得低于 `10` 分钟,以匹配产品侧“预计 10 分钟”的展示口径。
## 6. 资产与计费
资产写入:
| 产物 | assetKind | entityKind | slot |
| --- | --- | --- | --- |
| 故事板图 | `custom_world_opening_cg_storyboard` | `custom_world_profile` | `opening_cg_storyboard` |
| 成片视频 | `custom_world_opening_cg_video` | `custom_world_profile` | `opening_cg_video` |
计费:
1. 每次点击生成消耗 `80` 积分。
2. 故事板生成失败、视频任务创建失败、轮询失败、下载失败或 OSS 持久化失败都退款。
3. 扣费流水的 asset id 使用本次 opening CG id,避免同一次请求重试重复扣费。
## 7. 前端落点
1. `CustomWorldEntityCatalog` 在世界 Tab 增加开局 CG 槽。
2. `rpgCreationAssetClient` 新增 `generateOpeningCg`
3. `RpgCreationResultViewImpl` 持有生成中状态,生成完成后 `onProfileChange({ ...profile, openingCg })`
4. 视频展示使用签名 URL 读取组件,不把签名 URL 写入 profile。
5. 草稿生成时不调用该接口。
## 8. 验收点
1. 新草稿生成完成后 `openingCg` 为空或不存在。
2. 世界 Tab 可以手动生成开局 CG。
3. 生成请求 payload 包含角色参考图与开局第一幕背景图。
4. 故事板请求使用 `gpt-image-2``2048x1152`/`2k` 语义、`16:9`
5. 视频请求使用 Seedance、`480p``16:9``15` 秒,并传入故事板参考图。
6. 单次生成扣 `80` 积分,任一失败路径退款。
7. 生成成功后 profile 内出现 `openingCg.videoSrc`,刷新/保存/发布后能保留。
8. 视频链路上游超时不低于 `10` 分钟,避免低于产品展示的预计等待时长。
@@ -20,6 +20,17 @@
- 后端调用 `module-runtime-story` 纯规则结算动作,推进 `runtimeActionVersion`,写回 runtime snapshot,并用 `continue_story` 记录本轮 narrative event。
- 响应同样返回 `StoryRuntimeMutationResponse { projection }`,不返回旧 `viewModel / presentation / patches / snapshot` 组合。
## 版本口径
`StoryRuntimeProjectionResponse.serverVersion` 只表示动作并发版本,必须与 `projection.gameState.runtimeActionVersion` 保持一致。前端点击运行时选项时把该值作为 `clientVersion` 提交,后端只用它防止基于旧动作快照重复结算。
以下字段不能参与 `serverVersion` 计算:
1. `runtime_snapshot.version`:这是保存快照结构版本,当前由 `SAVE_SNAPSHOT_VERSION` 固定维护,写快照不会把它当作动作轮次递增。
2. `story_session.version`:这是故事事件流版本,`continue_story` 会推进它,但它不一定等同于当前运行时动作快照版本。
读取 `/runtime-projection` 和写入 `/actions/resolve` 的回包都必须从持久化 `gameState.runtimeActionVersion` 解析 `serverVersion`。如果旧快照缺少该字段,才允许回退到 `storySession.version` 或本轮 resolver 输出版本,避免历史存档无法恢复;不得再使用 `runtime_snapshot.version.max(story_session.version)` 这类混合口径。
## 契约收口
本轮新增 story contract 下的运行时写侧 DTO
@@ -0,0 +1,22 @@
# WP-SC Story runtime legacy option scope 兼容修复(2026-05-03
## 背景
`/api/story/sessions/{storySessionId}/runtime-projection` 读取侧在解析历史 `currentStory.options` 时,曾直接把 option JSON 反序列化为后端投影类型,并要求 `scope` 必填。
但旧快照里的 `currentStory.options` 只保证 `functionId` / `actionText` / `text` 等基础字段,`scope` 并不是历史存档的稳定字段。于是旧存档在读取 runtime inventory view 时会报:
`currentStory.options 无法映射为后端选项投影: missing field 'scope'`
## 修复口径
1. `spacetime-client` 的 story runtime projection 读取不再直接反序列化 `currentStory.options`
2. 改为复用 `module-runtime-story::build_runtime_story_options(...)`,让历史快照通过领域 helper 统一补齐 `story / combat / npc` 作用域。
3. 保持 `StoryRuntimeProjectionSource``StoryRuntimeProjectionResponse` 输出结构不变,不改 SpacetimeDB schema,不改 reducer,不改 API route。
## 验收
```powershell
cargo test -p spacetime-client story_runtime --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
```
@@ -37,12 +37,14 @@
本次只做前端分享引导,不接入微信、QQ、抖音的原生 SDK。点击渠道 icon 与主“分享”按钮保持一致,复制同一份分享文本。
仓库现有 `media/social-media-group/wechat.png``qq.png` 是社群二维码,不作为本面板渠道 icon 使用。渠道 icon 采用轻量圆形文字标识,避免误导用户进入社群
仓库现有 `media/social-media-group/wechat.png``qq.png` 是社群二维码,不作为本面板渠道 icon 使用。渠道 icon 必须使用微信、QQ、抖音的品牌 SVG 轮廓,外层保持圆形触控底座;不能用通用聊天气泡、音乐符号或纯文字替代 logo
## 面板样式约束
分享面板通过 `UnifiedModal` portal 挂载到页面根部时,需要在遮罩层补齐当前平台主题类,避免主题变量脱离页面容器后丢失。面板外壳继续使用 `platform-modal-shell``--platform-modal-fill` 背景,并在移动端覆盖平台弹窗默认底部抽屉布局,保持居中显示。
同类平台弹窗,包括删除作品等确认面板,也必须遵守同一条约束:portal 挂载时遮罩层必须带 `platform-theme platform-theme--light/dark`,面板必须保留 `platform-modal-shell` 的实体背景,不能把主面板做成透明或只依赖 backdrop blur。移动端高风险确认弹窗必须显式居中显示,避免被底部导航、安全区或底部抽屉布局遮住。
## 接入范围
- `RpgCreationResultActionBar`:RPG 发布成功后由父层回传分享数据并打开面板。
@@ -4,6 +4,7 @@ export type PuzzleAgentSuggestedActionType =
| 'request_summary'
| 'compile_puzzle_draft'
| 'generate_puzzle_images'
| 'generate_puzzle_tags'
| 'publish_puzzle_work';
export interface PuzzleAgentSuggestedAction {
@@ -16,6 +17,7 @@ export type PuzzleAgentActionType =
| 'save_puzzle_form_draft'
| 'compile_puzzle_draft'
| 'generate_puzzle_images'
| 'generate_puzzle_tags'
| 'select_puzzle_image'
| 'publish_puzzle_work';
@@ -71,6 +73,15 @@ export type PuzzleAgentActionRequest =
themeTags?: string[];
levelsJson?: string;
}
| {
action: 'generate_puzzle_tags';
workTitle: string;
workDescription: string;
levelName?: string;
summary?: string;
themeTags?: string[];
levelsJson?: string;
}
| {
action: 'select_puzzle_image';
levelId?: string | null;
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Some files were not shown because too many files have changed in this diff Show More