0e4c2ebef4
Squash-merge the cloudflare-migration branch (7 commits by Kai ki) into staging with conflict resolution, feature integration, and bug fixes. Engine: - Paradigm D: single-stream Writer replacing dual-phase Plan/Beats - Delete Architect agent; story bible generated via Writer <plan> tag - Modular prompt architecture (segments/registry/builder) - StreamRouter for tagged stream splitting (<plan>/<story>/<choices>) Infrastructure: - Cloudflare Workers deployment (wrangler.jsonc, OpenNext adapter) - D1 database schema + Drizzle ORM (scaffolded, not yet active) - R2 storage helpers (scaffolded, not yet active) - Story persistence API routes + client-side persistence BYOK (Bring Your Own Key): - /api/llm/user-proxy with SSRF-protected LLM proxy (+ requireUser auth) - CORS-aware fetch in ai-client: auto-detect CORS failure, fallback to server proxy transparently via OpenAI SDK custom fetch - BYO config support added to classify-freeform and vision routes - SettingsModal CORS privacy notice (keys never logged/stored) SSE streaming: - engineClient.ts: fetchSSE helper for progressive scene events - startSession/requestScene accept optional emit callback - Fix SSE error event field name (error → message) in scene/start routes i18n integration: - Wire buildLanguageDirective into paradigm D's prompt builder - Update corsNotice i18n keys (zh-CN/en/ja) with CORS proxy privacy text - Preserve Session.language + LanguageSwitcher from i18n commit Co-authored-by: Kai ki <155355644+zbf1009@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
158 lines
6.3 KiB
TypeScript
158 lines
6.3 KiB
TypeScript
/**
|
|
* migrate-featured.ts — 精选故事迁移脚本
|
|
*
|
|
* 从 app/page.tsx 的 STORIES 常量生成 featured_stories INSERT SQL。
|
|
* 输出 SQL 到 stdout(可通过 wrangler d1 execute 导入),或 --dry-run 预览。
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/migrate-featured.ts > drizzle/seed-featured.sql
|
|
* npx tsx scripts/migrate-featured.ts --dry-run
|
|
* wrangler d1 execute infiplot-db --file=drizzle/seed-featured.sql
|
|
*/
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
const DRY_RUN = process.argv.includes("--dry-run");
|
|
|
|
// ── Parse STORIES from app/page.tsx ──────────────────────────────────────
|
|
|
|
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
|
|
|
function extractStories(): Record<"男性向" | "女性向", StoryContent[]> {
|
|
const src = readFileSync(join(process.cwd(), "app/page.tsx"), "utf-8");
|
|
|
|
const startIdx = src.indexOf("const STORIES:");
|
|
if (startIdx === -1) throw new Error("Cannot find 'const STORIES:' in app/page.tsx");
|
|
|
|
const eqIdx = src.indexOf("= {", startIdx);
|
|
if (eqIdx === -1) throw new Error("Cannot find STORIES assignment");
|
|
|
|
let braceCount = 0;
|
|
let objStart = -1;
|
|
for (let i = eqIdx + 2; i < src.length; i++) {
|
|
if (src[i] === "{") {
|
|
if (objStart === -1) objStart = i;
|
|
braceCount++;
|
|
} else if (src[i] === "}") {
|
|
braceCount--;
|
|
if (braceCount === 0) {
|
|
const objStr = src.slice(objStart, i + 1);
|
|
// CR-11: Convert JS object literal to JSON safely (no eval/Function)
|
|
// 1. Wrap unquoted keys in double-quotes (中文 and ASCII keys)
|
|
// 2. Replace single-quotes with double-quotes
|
|
// 3. Remove trailing commas before } or ]
|
|
const jsonStr = objStr
|
|
.replace(/^\s*([\w一-鿿]+)\s*:/gm, '"$1":') // unquoted keys → quoted
|
|
.replace(/'/g, '"') // single → double quotes
|
|
.replace(/,\s*([}\]])/g, "$1"); // trailing commas
|
|
try {
|
|
return JSON.parse(jsonStr) as Record<"男性向" | "女性向", StoryContent[]>;
|
|
} catch (parseErr) {
|
|
throw new Error(`Failed to parse STORIES as JSON: ${(parseErr as Error).message}. Consider extracting STORIES to a standalone JSON file.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
throw new Error("Cannot parse STORIES object — unbalanced braces");
|
|
}
|
|
|
|
// ── Parse DISPLAY_ORDER from app/page.tsx ────────────────────────────────
|
|
|
|
function extractDisplayOrder(): Record<"男性向" | "女性向", number[]> {
|
|
const src = readFileSync(join(process.cwd(), "app/page.tsx"), "utf-8");
|
|
|
|
const startIdx = src.indexOf("const DISPLAY_ORDER:");
|
|
if (startIdx === -1) throw new Error("Cannot find 'const DISPLAY_ORDER:' in app/page.tsx");
|
|
|
|
const eqIdx = src.indexOf("= {", startIdx);
|
|
if (eqIdx === -1) throw new Error("Cannot find DISPLAY_ORDER assignment");
|
|
|
|
let braceCount = 0;
|
|
let objStart = -1;
|
|
for (let i = eqIdx + 2; i < src.length; i++) {
|
|
if (src[i] === "{") {
|
|
if (objStart === -1) objStart = i;
|
|
braceCount++;
|
|
} else if (src[i] === "}") {
|
|
braceCount--;
|
|
if (braceCount === 0) {
|
|
const objStr = src.slice(objStart, i + 1);
|
|
const fn = new Function(`return (${objStr})`);
|
|
return fn() as Record<"男性向" | "女性向", number[]>;
|
|
}
|
|
}
|
|
}
|
|
throw new Error("Cannot parse DISPLAY_ORDER object — unbalanced braces");
|
|
}
|
|
|
|
// ── Generate SQL ─────────────────────────────────────────────────────────
|
|
|
|
function escSql(s: string): string {
|
|
return s.replace(/'/g, "''");
|
|
}
|
|
|
|
function generateSql(): string {
|
|
const stories = extractStories();
|
|
const displayOrder = extractDisplayOrder();
|
|
|
|
const lines: string[] = [
|
|
"-- Auto-generated by scripts/migrate-featured.ts",
|
|
"-- Idempotent: uses INSERT OR REPLACE",
|
|
"",
|
|
"DELETE FROM featured_stories;",
|
|
"",
|
|
];
|
|
|
|
const genderMap: Record<string, string> = { "男性向": "male", "女性向": "female" };
|
|
const prefixMap: Record<string, string> = { "男性向": "m", "女性向": "f" };
|
|
|
|
for (const [genderCn, storyList] of Object.entries(stories)) {
|
|
const gender = genderMap[genderCn]!;
|
|
const prefix = prefixMap[genderCn]!;
|
|
const order = displayOrder[genderCn as keyof typeof displayOrder] ?? Array.from({ length: storyList.length }, (_, i) => i);
|
|
|
|
// Generate a sortOrder for each story based on its position in DISPLAY_ORDER
|
|
const sortOrderMap = new Map<number, number>();
|
|
for (let sortPos = 0; sortPos < order.length; sortPos++) {
|
|
sortOrderMap.set(order[sortPos]!, sortPos);
|
|
}
|
|
|
|
for (let i = 0; i < storyList.length; i++) {
|
|
const s = storyList[i]!;
|
|
const id = `${prefix}${i}`;
|
|
const sortOrder = sortOrderMap.get(i) ?? i;
|
|
const coverPath = `/home/${id}.webp`;
|
|
const firstactPath = `/home/firstact/${id}.json`;
|
|
const firstscenePath = `/home/firstscene/${id}.webp`;
|
|
const tagsJson = JSON.stringify(s.tags);
|
|
|
|
lines.push(
|
|
`INSERT OR REPLACE INTO featured_stories (id, gender, title, outline, style, tags, cover_path, firstact_path, firstscene_path, sort_order, is_active, click_count, created_at)` +
|
|
` VALUES ('${escSql(id)}', '${gender}', '${escSql(s.title)}', '${escSql(s.outline)}', '${escSql(s.style)}', '${escSql(tagsJson)}', '${escSql(coverPath)}', '${escSql(firstactPath)}', '${escSql(firstscenePath)}', ${sortOrder}, 1, 0, unixepoch());`,
|
|
);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// ── Main ─────────────────────────────────────────────────────────────────
|
|
|
|
try {
|
|
const sql = generateSql();
|
|
|
|
if (DRY_RUN) {
|
|
console.log("=== DRY RUN — SQL preview (not executing) ===\n");
|
|
console.log(sql);
|
|
console.log("\n=== END DRY RUN ===");
|
|
console.log(`\nTotal: ${sql.split("INSERT").length - 1} records`);
|
|
} else {
|
|
process.stdout.write(sql);
|
|
}
|
|
} catch (err) {
|
|
console.error("Migration script failed:", err instanceof Error ? err.message : err);
|
|
process.exit(1);
|
|
}
|