feat(inspirations): 灵感库 - 将内置英文提示词统一为中文 (#763)
* feat(inspirations): 灵感库 - 统一中文正文入口并校验译文版本 * feat(inspirations): 灵感库 - 发布完整中文提示词并阻止旧译文覆盖 统一 1319 条外部提示词和 329 条画廊引用,保留来源与模板参数;校验全部原文摘要,防止上游更新后误用过期中文。 验证:Bun 1.3.9 全量 2261 项测试通过,前端构建与 lint 通过,目录身份及媒体元数据对比无变化。
This commit is contained in:
@@ -491,6 +491,10 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph
|
||||
|
||||
工作区左侧「创作」下方新增灵感页 `/inspirations`,展示全部提示词,按来源、分类和创作类型筛选,支持搜索、收藏、视图切换和分页;自建灵感支持新建、编辑、删除和 JSON 导入导出,改动只存在本地。
|
||||
|
||||
内置灵感的提示词统一提供中文正文:详情阅读、复制、编辑和一键带入创作使用同一份中文内容,首页画廊保持一致。已有中文来源直接采用中文版本,其余外文正文翻译后随静态目录发布,不需要客户等待在线翻译。模型名、引用标记和模板参数保留原有用途。上游原页仍可从来源链接访问。
|
||||
|
||||
译文维护在 `web/scripts/data/inspiration-prompts-zh.json`,记录原文 SHA-256;导入脚本在写入目录前校验原文版本。上游正文更新或新增条目时需补齐匹配的译文,缺译会停止导入,避免重新发布英文提示词。
|
||||
|
||||
生产 Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,支持直接打开和刷新;`/inspirations/*.json` 继续提供静态目录数据,避免页面路径与同名目录冲突而跳转到容器端口。
|
||||
|
||||
外部提示词由 `web/scripts/import-inspirations.mjs` 同步:`awesome-seedance`、好好学 AI 提示词库、YouMind 的两个姊妹库。脚本同时为画廊池生成本地缩略图(560px、webp、约 35KB 一张)。上游封面平均 400KB~700KB,直接引用一屏 11 张要拉 5MB 以上,换成本地缩略图后约 400KB。
|
||||
|
||||
@@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 灵感库中文提示词
|
||||
|
||||
- 已实现:内置目录与首页画廊的提示词统一为中文;复用来源中文正文,补齐外文翻译;导入时校验原文摘要,缺译或旧译文阻止覆盖。
|
||||
- [ ] 用户验收:抽查图片、长视频分镜和模板参数,确认中文表达、时长、镜头要求和参考素材语义符合预期。
|
||||
- [ ] 用户验收:本地及线上刷新后,详情、复制、编辑和一键创作均使用中文,收藏与自定义修改保持正常。
|
||||
|
||||
## 创作页提交闸门只覆盖提交阶段
|
||||
|
||||
- 已修复:`submit()` 的提交闸门原来一直持有到整条生成结束(只在 `finally` 释放,而 `finally` 要等任务出结果才执行),因此上一条还在生成时点发送会被 `tryAcquire()` **静默拒绝**——没有提示也没有反应,表现为「按钮在转、点不动」。重试走同一条 submit 路径,一并被挡住。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
5293
web/scripts/data/inspiration-prompts-zh.json
Normal file
5293
web/scripts/data/inspiration-prompts-zh.json
Normal file
File diff suppressed because one or more lines are too long
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { localizeInspirationEntries } from "./inspiration-localization.mjs";
|
||||
|
||||
const WEB_DIR = resolve(import.meta.dir, "..");
|
||||
const PUBLIC_DIR = join(WEB_DIR, "public", "inspirations");
|
||||
@@ -487,6 +488,14 @@ async function main() {
|
||||
const selected = SOURCE_PIPELINES.filter((pipeline) => !options.only || options.only === pipeline.name);
|
||||
if (!selected.length) throw new Error(`--only 不认识的来源: ${options.only}`);
|
||||
|
||||
const translations = JSON.parse(readFileSync(join(WEB_DIR, "scripts", "data", "inspiration-prompts-zh.json"), "utf8"));
|
||||
const localizedSources = new Map();
|
||||
// 先检查所有选中来源的中文覆盖,再下载封面或写目录,避免半批更新后才发现缺译。
|
||||
for (const pipeline of selected) {
|
||||
const loaded = await pipeline.load(options);
|
||||
localizedSources.set(pipeline.name, { ...loaded, entries: localizeInspirationEntries(loaded.entries, translations) });
|
||||
}
|
||||
|
||||
mkdirSync(PUBLIC_DIR, { recursive: true });
|
||||
const revisions = {};
|
||||
const counts = {};
|
||||
@@ -508,7 +517,7 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
const loaded = await pipeline.load(options);
|
||||
const loaded = localizedSources.get(pipeline.name);
|
||||
const { entries } = loaded;
|
||||
if (!entries.length) throw new Error(`${pipeline.name} 没有解析出任何条目`);
|
||||
revisions[pipeline.name] = loaded.revision;
|
||||
|
||||
20
web/scripts/inspiration-localization.mjs
Normal file
20
web/scripts/inspiration-localization.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** 原文变更必须重新确认译文,不能只按 id 套用已过期的中文。 */
|
||||
export function localizeInspirationEntries(entries, translations) {
|
||||
return entries.map(({ promptZh, ...entry }) => {
|
||||
const translation = translations[entry.id];
|
||||
const sourceHash = createHash("sha256").update(entry.prompt).digest("hex");
|
||||
if (!translation || translation.sourceHash !== sourceHash) {
|
||||
throw new Error(`灵感缺少匹配当前原文的中文正文:${entry.id}。请更新 scripts/data/inspiration-prompts-zh.json 后再导入。`);
|
||||
}
|
||||
const prompt = translation.prompt?.trim();
|
||||
if (!prompt || !/[㐀-䶿一-鿿]/.test(prompt)) throw new Error(`灵感中文正文无效:${entry.id}`);
|
||||
return {
|
||||
...entry,
|
||||
...(translation?.title ? { title: translation.title } : {}),
|
||||
...(translation?.description ? { description: translation.description } : {}),
|
||||
prompt,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export const inspirationSources: Record<InspirationSourceId, InspirationSourceIn
|
||||
repository: "https://github.com/LearnPrompt/awesome-seedance",
|
||||
revision: HIGHLIGHT_REVISIONS.seedance,
|
||||
license: "CC BY 4.0",
|
||||
notice: "视频提示词与封面来自 awesome-seedance(数据每日同步自 goodcase.ai),按 CC BY 4.0 署名使用。封面为原案例画面,不代表本平台生成结果。",
|
||||
notice: "视频提示词与封面来自 awesome-seedance(数据每日同步自 goodcase.ai),提示词经中文翻译改编,按 CC BY 4.0 署名使用。封面为原案例画面,不代表本平台生成结果。",
|
||||
},
|
||||
haohaoxue: {
|
||||
label: "好好学 AI · 图片提示词",
|
||||
@@ -40,7 +40,7 @@ export const inspirationSources: Record<InspirationSourceId, InspirationSourceIn
|
||||
repository: "https://www.haohaoxue.com/prompts/",
|
||||
revision: HIGHLIGHT_REVISIONS.haohaoxue,
|
||||
license: "站点公开数据 · robots.txt 允许抓取",
|
||||
notice: "图片提示词中英双语正文与封面来自好好学 AI 提示词库(haohaoxue.com),该站 robots.txt 明确允许包括 ClaudeBot 在内的抓取。作者署名取数据内置的 author 字段。封面为原提示词示例图,不代表本平台生成结果。",
|
||||
notice: "图片提示词中文正文与封面来自好好学 AI 提示词库(haohaoxue.com),该站 robots.txt 明确允许包括 ClaudeBot 在内的抓取。作者署名取数据内置的 author 字段。封面为原提示词示例图,不代表本平台生成结果。",
|
||||
},
|
||||
youmind: {
|
||||
label: "YouMind 提示词库 · CC BY 4.0",
|
||||
@@ -48,7 +48,7 @@ export const inspirationSources: Record<InspirationSourceId, InspirationSourceIn
|
||||
repository: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts",
|
||||
revision: HIGHLIGHT_REVISIONS.youmind,
|
||||
license: "CC BY 4.0",
|
||||
notice: "图片提示词与配图来自 YouMind OpenLab 的 awesome-nano-banana-pro-prompts 与 awesome-gpt-image-2 两个姊妹库(Nano Banana Pro / GPT Image 2 中文提示词),按 CC BY 4.0 署名使用。作者署名取条目内置的 author 字段。配图为原提示词的生成示例,不代表本平台生成结果。",
|
||||
notice: "图片提示词与配图来自 YouMind OpenLab 的 awesome-nano-banana-pro-prompts 与 awesome-gpt-image-2 两个姊妹库(Nano Banana Pro / GPT Image 2 中文提示词),部分提示词经中文翻译改编,按 CC BY 4.0 署名使用。作者署名取条目内置的 author 字段。配图为原提示词的生成示例,不代表本平台生成结果。",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export type CreationInspiration = {
|
||||
image: string;
|
||||
mode: CreationMode;
|
||||
prompt: string;
|
||||
/** 中文正文,双语来源才有(好好学 AI 的中英双语条目)。 */
|
||||
/** 自定义双语条目的中文正文;内置目录已将中文统一存入 prompt。 */
|
||||
promptZh?: string;
|
||||
/** 上游分类 id,标签表见 public/inspirations/manifest.json。 */
|
||||
category?: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ArrowUp, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
|
||||
import { catalogIdOf, type CreationInspiration } from "@/lib/inspirations/catalog";
|
||||
import { fillablePrompt } from "@/lib/inspirations/resolve";
|
||||
import { loadGalleryPool } from "@/services/inspiration-catalog";
|
||||
import type { CreationMode } from "./creation-types";
|
||||
import { createGalleryWheelGesture } from "./creation-gallery-gesture";
|
||||
@@ -228,7 +229,7 @@ export function CreationInspirationTunnel({ mode, onStartPrompt }: { mode: Creat
|
||||
tabIndex={-1}
|
||||
className={`creation-tunnel-card ${depth === 0 ? "is-active" : ""} ${readyIds.has(id) ? "is-ready" : ""}`}
|
||||
style={style}
|
||||
onClick={() => onStartPrompt(inspiration.mode, inspiration.prompt)}
|
||||
onClick={() => onStartPrompt(inspiration.mode, fillablePrompt(inspiration))}
|
||||
>
|
||||
<img
|
||||
src={inspiration.image}
|
||||
|
||||
48
web/test/inspiration-localization.test.ts
Normal file
48
web/test/inspiration-localization.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { localizeInspirationEntries } from "../scripts/inspiration-localization.mjs";
|
||||
|
||||
const hash = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
const original = { id: "seedance:sample", prompt: "A slow tracking shot, 16:9, 10 seconds.", image: "/cover.webp", videoUrl: "https://example.com/video.mp4", sourceUrl: "https://example.com/source" };
|
||||
|
||||
describe("Chinese inspiration catalogs", () => {
|
||||
test("replaces the complete prompt while preserving media, identity and attribution", () => {
|
||||
const translated = "缓慢跟拍镜头,16:9,10 秒。";
|
||||
const [entry] = localizeInspirationEntries([original], { [original.id]: { sourceHash: hash(original.prompt), prompt: translated, title: "缓慢跟拍", description: "横屏十秒镜头" } });
|
||||
expect(entry).toEqual({ ...original, prompt: translated, title: "缓慢跟拍", description: "横屏十秒镜头" });
|
||||
expect(original.prompt).toStartWith("A slow");
|
||||
});
|
||||
|
||||
test("uses the verified translation even when upstream supplies another Chinese body", () => {
|
||||
const [entry] = localizeInspirationEntries([{ ...original, promptZh: "原站提供的旧中文提示词。" }], { [original.id]: { sourceHash: hash(original.prompt), prompt: "已校对的完整中文提示词。" } });
|
||||
expect(entry.prompt).toBe("已校对的完整中文提示词。");
|
||||
expect(entry.promptZh).toBeUndefined();
|
||||
});
|
||||
|
||||
test("upstream Chinese cannot bypass missing or stale translation approval", () => {
|
||||
for (const records of [{}, { [original.id]: { sourceHash: hash("old prompt"), prompt: "旧翻译" } }]) {
|
||||
expect(() => localizeInspirationEntries([{ ...original, promptZh: "旧中文" }], records)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects missing, outdated and non-Chinese translations instead of restoring English", () => {
|
||||
for (const records of [{}, { [original.id]: { sourceHash: hash("old prompt"), prompt: "旧翻译" } }, { [original.id]: { sourceHash: hash(original.prompt), prompt: "Still English" } }]) {
|
||||
expect(() => localizeInspirationEntries([original], records)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("every shipped catalog prompt is Chinese and the gallery uses the same complete text", () => {
|
||||
const all = new Map<string, string>();
|
||||
for (const name of ["seedance", "haohaoxue", "youmind"]) {
|
||||
const pack = JSON.parse(readFileSync(new URL(`../public/inspirations/${name}.json`, import.meta.url), "utf8"));
|
||||
for (const item of pack.entries) {
|
||||
expect(item.prompt).toMatch(/[\u3400-\u9fff]/);
|
||||
expect(item.promptZh).toBeUndefined();
|
||||
all.set(item.id, item.prompt);
|
||||
}
|
||||
}
|
||||
const gallery = JSON.parse(readFileSync(new URL("../public/inspirations/gallery-pool.json", import.meta.url), "utf8"));
|
||||
for (const item of gallery.entries) expect(item.prompt).toBe(all.get(item.id));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user