diff --git a/README-MAC.md b/README-MAC.md new file mode 100644 index 000000000..089ff42aa --- /dev/null +++ b/README-MAC.md @@ -0,0 +1,31 @@ +# mac 打开方式 + +## 直接打开 + +双击项目根目录里的 `启动.command`。 + +第一次打开会自动安装这个项目需要的本地依赖,时间会久一点;之后再打开会快很多。 + +## 需要先准备 + +- Node.js 20.19、22.12 或更新版本 +- 能正常访问 npm / Electron 下载源的网络 +- 至少一组可用的 AI 模型 API Key + +API Key 可以启动后在页面里的模型设置中填写,不需要手动改 `.env` 文件。 + +## 常见情况 + +- 如果 mac 提示无法打开 `启动.command`,在终端里进入项目目录执行:`chmod +x 启动.command mac/start-mac.sh` +- 如果启动卡在下载 Electron,通常是网络访问 GitHub Releases 不稳定,换网络或配置代理后再试。 +- 如果暂时不使用知识库,启动器默认会关闭 RAG,先跑通写作主流程。 + +## 打包成 mac 安装包 + +如果你以后想生成真正的 `.app` / `.dmg`,先双击启动成功一次,再执行: + +```bash +pnpm run dist:desktop:mac +``` + +生成结果会放在 `desktop/build/dist/`。 diff --git a/client/src/pages/novels/NovelEdit.tsx b/client/src/pages/novels/NovelEdit.tsx index 726a7836d..11d542889 100644 --- a/client/src/pages/novels/NovelEdit.tsx +++ b/client/src/pages/novels/NovelEdit.tsx @@ -477,7 +477,13 @@ export default function NovelEdit() { }, onSuccess: ({ blob, fileName, scope }) => { createDownload(blob, fileName); - toast.success(scope === "full" ? "整本书导出已开始。" : "当前步骤导出已开始。"); + toast.success( + scope === "full" + ? "整本书导出已开始。" + : scope === "setup_bundle" + ? "小说设定导出已开始。" + : "当前步骤导出已开始。", + ); }, onError: (error) => { toast.error(error instanceof Error ? error.message : "导出失败。"); @@ -2526,6 +2532,12 @@ export default function NovelEdit() { const isExportingCurrentJson = exportNovelMutation.isPending && exportVariables?.scope === currentExportScope && exportVariables?.format === "json"; + const isExportingSetupBundleMarkdown = exportNovelMutation.isPending + && exportVariables?.scope === "setup_bundle" + && exportVariables?.format === "markdown"; + const isExportingSetupBundleJson = exportNovelMutation.isPending + && exportVariables?.scope === "setup_bundle" + && exportVariables?.format === "json"; const isExportingFullMarkdown = exportNovelMutation.isPending && exportVariables?.scope === "full" && exportVariables?.format === "markdown"; @@ -2543,6 +2555,8 @@ export default function NovelEdit() { canExportCurrentStep: Boolean(currentExportScope), isExportingCurrentMarkdown, isExportingCurrentJson, + isExportingSetupBundleMarkdown, + isExportingSetupBundleJson, isExportingFullMarkdown, isExportingFullJson, onExportCurrent: (format) => { @@ -2555,6 +2569,13 @@ export default function NovelEdit() { novelTitle: exportNovelTitle, }); }, + onExportSetupBundle: (format) => { + exportNovelMutation.mutate({ + format, + scope: "setup_bundle", + novelTitle: exportNovelTitle, + }); + }, onExportFull: (format) => { exportNovelMutation.mutate({ format, diff --git a/client/src/pages/novels/components/NovelEditView.tsx b/client/src/pages/novels/components/NovelEditView.tsx index 1c1aa8e81..214e8fbbb 100644 --- a/client/src/pages/novels/components/NovelEditView.tsx +++ b/client/src/pages/novels/components/NovelEditView.tsx @@ -196,10 +196,10 @@ function DesktopNovelEditView(props: NovelEditViewProps) { 导出项目内容 - 当前步骤会按你正在查看的工作台导出;整本书会把项目设定、故事规划、角色、卷规划、拆章、章节和质量修复资产一起导出。 + 小说设定会包含项目设定、故事规划和角色准备,适合带去别处继续生成正文。 -
+
当前步骤:{currentStepLabel} @@ -221,6 +221,27 @@ function DesktopNovelEditView(props: NovelEditViewProps) { + + + 小说设定 + + + + + + 整本书 diff --git a/client/src/pages/novels/components/NovelEditView.types.ts b/client/src/pages/novels/components/NovelEditView.types.ts index 077719fbc..3ce539767 100644 --- a/client/src/pages/novels/components/NovelEditView.types.ts +++ b/client/src/pages/novels/components/NovelEditView.types.ts @@ -603,9 +603,12 @@ export interface NovelEditViewProps { canExportCurrentStep: boolean; isExportingCurrentMarkdown: boolean; isExportingCurrentJson: boolean; + isExportingSetupBundleMarkdown: boolean; + isExportingSetupBundleJson: boolean; isExportingFullMarkdown: boolean; isExportingFullJson: boolean; onExportCurrent: (format: NovelExportDownloadFormat) => void; + onExportSetupBundle: (format: NovelExportDownloadFormat) => void; onExportFull: (format: NovelExportDownloadFormat) => void; }; basicTab: BasicTabProps; diff --git a/client/src/pages/novels/mobile/MobileNovelEditView.tsx b/client/src/pages/novels/mobile/MobileNovelEditView.tsx index 9fa5e1887..6261c3f63 100644 --- a/client/src/pages/novels/mobile/MobileNovelEditView.tsx +++ b/client/src/pages/novels/mobile/MobileNovelEditView.tsx @@ -130,7 +130,7 @@ export default function MobileNovelEditView(props: NovelEditViewProps) { 创作工具 - 查看任务进度,导出当前步骤或整本书内容。 + 查看任务进度,导出设定或正文内容。
@@ -185,6 +185,28 @@ export default function MobileNovelEditView(props: NovelEditViewProps) {
+
+
导出小说设定
+
+ + +
+
+
导出整本书
diff --git a/desktop/builder/app-icon.icns b/desktop/builder/app-icon.icns new file mode 100644 index 000000000..aa7a25318 Binary files /dev/null and b/desktop/builder/app-icon.icns differ diff --git a/desktop/electron-builder.config.cjs b/desktop/electron-builder.config.cjs index 1305255b7..631a7cde3 100644 --- a/desktop/electron-builder.config.cjs +++ b/desktop/electron-builder.config.cjs @@ -25,7 +25,8 @@ const allowUnsignedRelease = process.env.AI_NOVEL_ALLOW_UNSIGNED_WINDOWS_RELEASE, ).toLowerCase() === "true"; const hasWindowsSigningMaterial = Boolean(windowsSigningLink); -const builderIconPath = path.join("builder", "app-icon.ico"); +const builderWindowsIconPath = path.join("builder", "app-icon.ico"); +const builderMacIconPath = path.join("builder", "app-icon.icns"); if (!isBetaRelease && !hasWindowsSigningMaterial && !allowUnsignedRelease) { throw new Error( @@ -51,6 +52,10 @@ module.exports = { from: "builder/app-icon.ico", to: "icons/app-icon.ico", }, + { + from: "builder/app-icon.icns", + to: "icons/app-icon.icns", + }, { from: "build/resources/app-update.yml", to: "app-update.yml", @@ -81,7 +86,7 @@ module.exports = { electronUpdaterCompatibility: ">=2.16", generateUpdatesFilesForAllChannels: false, win: { - icon: builderIconPath, + icon: builderWindowsIconPath, // Keep EXE resource editing enabled for unsigned builds so Windows uses the app icon and metadata. signAndEditExecutable: true, target: [ @@ -105,9 +110,20 @@ module.exports = { createStartMenuShortcut: true, deleteAppDataOnUninstall: false, runAfterFinish: true, - installerIcon: builderIconPath, - uninstallerIcon: builderIconPath, - installerHeaderIcon: builderIconPath, + installerIcon: builderWindowsIconPath, + uninstallerIcon: builderWindowsIconPath, + installerHeaderIcon: builderWindowsIconPath, + }, + mac: { + icon: builderMacIconPath, + category: "public.app-category.productivity", + target: ["dmg", "zip"], + artifactName: "${productName}-${version}-mac-${arch}.${ext}", + hardenedRuntime: false, + gatekeeperAssess: false, + }, + dmg: { + artifactName: "${productName}-${version}-mac-${arch}.${ext}", }, portable: { artifactName: "${productName}-${version}-portable-${arch}.${ext}", diff --git a/desktop/scripts/generate-desktop-icons.py b/desktop/scripts/generate-desktop-icons.py index 939384f75..ed12e8f29 100644 --- a/desktop/scripts/generate-desktop-icons.py +++ b/desktop/scripts/generate-desktop-icons.py @@ -116,6 +116,7 @@ def main() -> None: generated_images[512].save(BUILDER_DIR / "app-icon.png") generated_images[512].save(BUILDER_DIR / "app-icon.ico", sizes=ICO_SIZES) + generated_images[512].save(BUILDER_DIR / "app-icon.icns") print(f"Generated desktop icon assets in {BUILDER_DIR}") diff --git a/desktop/src/runtime/paths.ts b/desktop/src/runtime/paths.ts index 430cc0de8..08842103c 100644 --- a/desktop/src/runtime/paths.ts +++ b/desktop/src/runtime/paths.ts @@ -105,12 +105,14 @@ export function resolveDesktopWindowIcon(): string { return path.resolve(process.env.AI_NOVEL_DESKTOP_ICON_PATH.trim()); } - const packagedIconPath = path.join(resolveDesktopResourcesDir(), "icons", "app-icon.ico"); + const packagedIconName = process.platform === "darwin" ? "app-icon.icns" : "app-icon.ico"; + const packagedIconPath = path.join(resolveDesktopResourcesDir(), "icons", packagedIconName); if (fs.existsSync(packagedIconPath)) { return packagedIconPath; } - return path.resolve(resolveWorkspaceRoot(), "desktop", "builder", "app-icon.ico"); + const workspaceIconName = process.platform === "darwin" ? "app-icon.png" : "app-icon.ico"; + return path.resolve(resolveWorkspaceRoot(), "desktop", "builder", workspaceIconName); } export function resolvePackagedServerEntry(): string { diff --git a/mac/start-mac.sh b/mac/start-mac.sh new file mode 100755 index 000000000..60e3b250d --- /dev/null +++ b/mac/start-mac.sh @@ -0,0 +1,78 @@ +#!/bin/zsh +set -e + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +print_header() { + echo "" + echo "AI 小说创作工作台 - mac 启动器" + echo "--------------------------------" +} + +fail_with_help() { + echo "" + echo "$1" + echo "" + echo "可以先看 README-MAC.md,里面写了最少需要准备什么。" + exit 1 +} + +resolve_pnpm() { + if command -v pnpm >/dev/null 2>&1; then + echo "pnpm" + return + fi + + if command -v corepack >/dev/null 2>&1; then + echo "corepack pnpm" + return + fi + + fail_with_help "没找到 pnpm。请先安装 Node.js 20.19 或更新版本,再重新打开这个启动器。" +} + +check_node() { + if ! command -v node >/dev/null 2>&1; then + fail_with_help "没找到 Node.js。请先安装 Node.js 20.19 或更新版本。" + fi + + node -e 'const [major, minor] = process.versions.node.split(".").map(Number); if (!((major === 20 && minor >= 19) || (major === 22 && minor >= 12) || major >= 24)) process.exit(1);' \ + || fail_with_help "当前 Node.js 版本太低。这个项目需要 Node.js 20.19、22.12 或更新版本。" +} + +is_port_available() { + node -e 'const net = require("node:net"); const port = Number(process.argv[1]); const server = net.createServer(); server.once("error", () => process.exit(1)); server.listen(port, "127.0.0.1", () => server.close(() => process.exit(0)));' "$1" +} + +find_free_port() { + node -e 'const net = require("node:net"); const start = Number(process.argv[1]); function tryPort(port) { const server = net.createServer(); server.once("error", () => tryPort(port + 1)); server.listen(port, "127.0.0.1", () => server.close(() => { console.log(port); })); } tryPort(start);' "$1" +} + +print_header +check_node + +PNPM_CMD="$(resolve_pnpm)" + +if [ ! -d "node_modules" ]; then + echo "第一次打开需要准备依赖,时间会稍久一点。" + eval "$PNPM_CMD install" +fi + +export AI_NOVEL_DATABASE_MODE="${AI_NOVEL_DATABASE_MODE:-sqlite}" +export AI_NOVEL_RUNTIME="${AI_NOVEL_RUNTIME:-desktop}" +export RAG_ENABLED="${RAG_ENABLED:-false}" +export ALLOW_LAN="${ALLOW_LAN:-false}" +export HOST="${AI_NOVEL_HOST:-127.0.0.1}" + +if [ -z "$PORT" ] && ! is_port_available 3000; then + export PORT="$(find_free_port 43123)" + echo "检测到 3000 端口已被占用,本次改用 $PORT。" +fi + +echo "" +echo "正在启动,稍等片刻会打开桌面窗口。" +echo "如果要退出,关闭桌面窗口后也可以直接关闭这个终端。" +echo "" + +eval "$PNPM_CMD dev:desktop" diff --git a/package.json b/package.json index fcd95d3bb..2d8ed12c8 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "dev:server": "pnpm --filter @ai-novel/server dev", "dev:server:log": "node scripts/run-with-log.cjs --name server -- pnpm --filter @ai-novel/server dev", "dev:client": "pnpm --filter @ai-novel/client dev", - "dev:client:wait": "node scripts/wait-for-port.cjs --port 3000 && pnpm dev:client", + "dev:client:wait": "node scripts/wait-for-port.cjs --port-env PORT --port 3000 && pnpm dev:client", "prepare:desktop-runtime": "pnpm --filter @ai-novel/desktop prepare:runtime", - "dev:desktop:shell": "node scripts/wait-for-port.cjs --port 3000 && node scripts/wait-for-port.cjs --port 5173 && pnpm --filter @ai-novel/desktop dev", + "dev:desktop:shell": "node scripts/wait-for-port.cjs --port-env PORT --port 3000 && node scripts/wait-for-port.cjs --port 5173 && pnpm --filter @ai-novel/desktop dev", "dev:desktop": "node scripts/run-with-log.cjs --name desktop-dev -- pnpm dev:desktop:raw", "dev:desktop:raw": "concurrently \"pnpm dev:shared\" \"pnpm dev:server\" \"pnpm dev:client:wait\" \"pnpm dev:desktop:shell\"", "db:migrate": "pnpm --filter @ai-novel/server db:migrate", @@ -34,6 +34,8 @@ "dist:desktop:reuse-stage": "node desktop/scripts/run-electron-builder.cjs --win --x64", "dist:desktop:portable": "pnpm run stage:desktop && node desktop/scripts/run-electron-builder.cjs --win portable --x64", "dist:desktop:portable:reuse-stage": "node desktop/scripts/run-electron-builder.cjs --win portable --x64", + "dist:desktop:mac": "pnpm run stage:desktop && CSC_IDENTITY_AUTO_DISCOVERY=false node desktop/scripts/run-electron-builder.cjs --mac dmg zip", + "dist:desktop:mac:reuse-stage": "CSC_IDENTITY_AUTO_DISCOVERY=false node desktop/scripts/run-electron-builder.cjs --mac dmg zip", "publish:desktop:beta": "pnpm run stage:desktop && node desktop/scripts/publish-desktop-beta.cjs", "publish:desktop:beta:reuse-stage": "node desktop/scripts/publish-desktop-beta.cjs", "publish:desktop:release": "pnpm run stage:desktop && node desktop/scripts/publish-desktop-release.cjs", diff --git a/scripts/wait-for-port.cjs b/scripts/wait-for-port.cjs index ec835fd0e..2b7a0079a 100644 --- a/scripts/wait-for-port.cjs +++ b/scripts/wait-for-port.cjs @@ -19,6 +19,7 @@ function parseArgs(argv) { ? parseHosts(process.env.WAIT_FOR_PORT_HOSTS) : [...DEFAULT_HOSTS], port: 3000, + portEnv: "", timeoutMs: 120000, intervalMs: 500, help: false, @@ -44,6 +45,12 @@ function parseArgs(argv) { continue; } + if (arg === "--port-env" && argv[index + 1]) { + options.portEnv = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--timeout" && argv[index + 1]) { options.timeoutMs = Number(argv[index + 1]); index += 1; @@ -57,11 +64,18 @@ function parseArgs(argv) { } } + if (options.portEnv) { + const envPort = Number(process.env[options.portEnv] ?? ""); + if (Number.isInteger(envPort) && envPort > 0 && envPort <= 65535) { + options.port = envPort; + } + } + return options; } function printHelp() { - console.log("Usage: node scripts/wait-for-port.cjs [--host 127.0.0.1,localhost,::1] [--port 3000] [--timeout 120000] [--interval 500]"); + console.log("Usage: node scripts/wait-for-port.cjs [--host 127.0.0.1,localhost,::1] [--port 3000] [--port-env PORT] [--timeout 120000] [--interval 500]"); } function wait(ms) { diff --git a/server/scripts/ensure-dev-prisma.cjs b/server/scripts/ensure-dev-prisma.cjs index 96928a655..47d7c3ac5 100644 --- a/server/scripts/ensure-dev-prisma.cjs +++ b/server/scripts/ensure-dev-prisma.cjs @@ -6,7 +6,12 @@ const rootDir = path.resolve(__dirname, ".."); const repoRoot = path.resolve(rootDir, ".."); const generatedClientPath = path.join(rootDir, "node_modules", "@prisma", "client", "index.js"); const stampPath = path.join(rootDir, ".tmp", "prisma-dev-prepare.json"); -const prismaCliPath = path.join(rootDir, "node_modules", "prisma", "build", "index.js"); +const prismaBinPath = path.join( + rootDir, + "node_modules", + ".bin", + process.platform === "win32" ? "prisma.cmd" : "prisma", +); function normalizeDatabaseMode(rawValue) { const normalized = rawValue?.trim().toLowerCase(); @@ -46,10 +51,11 @@ function readJson(filePath) { } function runPrisma(args) { - const result = spawnSync(process.execPath, [prismaCliPath, ...args], { + const result = spawnSync(prismaBinPath, args, { cwd: rootDir, stdio: "inherit", env: process.env, + shell: process.platform === "win32", }); if (result.status !== 0) { process.exit(result.status ?? 1); @@ -171,6 +177,10 @@ function resolveSqliteDbPath(databaseUrl) { function main() { const runtimeConfig = resolveDatabaseRuntimeConfig(); + const isDesktopSqliteRuntime = + process.env.AI_NOVEL_RUNTIME?.trim().toLowerCase() === "desktop" + && runtimeConfig.provider === "sqlite"; + if (runtimeConfig.provider === "sqlite") { ensureBetterSqlite3Binding(); } @@ -197,9 +207,11 @@ function main() { runPrisma(["generate", "--schema", runtimeConfig.prismaSchemaPath]); } - if (schemaChanged || missingDb) { + if ((schemaChanged || missingDb) && !isDesktopSqliteRuntime) { console.log("[dev-prisma] running prisma push..."); runPrisma(["db", "push", "--schema", runtimeConfig.prismaSchemaPath]); + } else if (schemaChanged || missingDb) { + console.log("[dev-prisma] desktop SQLite runtime will apply migrations on server startup."); } fs.mkdirSync(path.dirname(stampPath), { recursive: true }); diff --git a/server/src/services/novel/export/novelExportFormatting.ts b/server/src/services/novel/export/novelExportFormatting.ts index 574ad8084..2324bf88f 100644 --- a/server/src/services/novel/export/novelExportFormatting.ts +++ b/server/src/services/novel/export/novelExportFormatting.ts @@ -1,5 +1,7 @@ import { NOVEL_EXPORT_SCOPE_LABELS, type NovelExportScope } from "@ai-novel/shared/types/novelExport"; import type { + ExportCharacter, + ExportNovelDetail, NovelExportBasicSection, NovelExportBundle, NovelExportCharacterSection, @@ -12,6 +14,78 @@ import type { NovelExportStructuredSection, } from "./novelExportTypes"; +type SetupBundleBasicSection = Pick & { + novel: Pick< + ExportNovelDetail, + | "id" + | "title" + | "description" + | "targetAudience" + | "bookSellingPoint" + | "competingFeel" + | "first30ChapterPromise" + | "commercialTags" + | "status" + | "writingMode" + | "projectMode" + | "narrativePov" + | "pacePreference" + | "styleTone" + | "emotionIntensity" + | "aiFreedom" + | "defaultChapterLength" + | "estimatedChapterCount" + | "genre" + | "primaryStoryMode" + | "secondaryStoryMode" + | "world" + | "createdAt" + | "updatedAt" + >; +}; + +type SetupBundleCharacter = Pick< + ExportCharacter, + | "id" + | "name" + | "role" + | "gender" + | "castRole" + | "storyFunction" + | "relationToProtagonist" + | "personality" + | "background" + | "development" + | "outerGoal" + | "innerNeed" + | "fear" + | "wound" + | "misbelief" + | "secret" + | "moralLine" + | "firstImpression" + | "appearance" + | "physique" + | "attireStyle" + | "signatureDetail" + | "voiceTexture" + | "presenceImpression" + | "arcStart" + | "arcMidpoint" + | "arcClimax" + | "arcEnd" +>; + +type SetupBundlePayload = { + basic: SetupBundleBasicSection; + story_macro: NovelExportStoryMacroSection; + character: { + characters: SetupBundleCharacter[]; + relations: NovelExportCharacterSection["relations"]; + castOptions: []; + }; +}; + const FULL_SECTION_ORDER: NovelExportSectionScope[] = [ "basic", "story_macro", @@ -22,6 +96,12 @@ const FULL_SECTION_ORDER: NovelExportSectionScope[] = [ "pipeline", ]; +const SETUP_BUNDLE_SECTION_ORDER: NovelExportSectionScope[] = [ + "basic", + "story_macro", + "character", +]; + function normalizeText(input: string | null | undefined): string { return (input ?? "").replace(/\r\n?/g, "\n").trim(); } @@ -197,6 +277,139 @@ function buildSectionSummary(scope: NovelExportSectionScope, section: NovelExpor } } +function buildSetupCharacterSummary(section: SetupBundlePayload["character"]): string[] { + const lines: string[] = []; + addBullet(lines, "角色数", section.characters.length); + addBullet(lines, "关系数", section.relations.length); + if (section.characters.length > 0) { + lines.push("### 角色设定"); + lines.push(""); + for (const character of section.characters) { + lines.push(`#### ${character.name}`); + lines.push(""); + addBullet(lines, "定位", character.role); + addBullet(lines, "阵容位置", character.castRole); + addBullet(lines, "故事功能", character.storyFunction); + addBullet(lines, "与主角关系", character.relationToProtagonist); + addBullet(lines, "外在目标", character.outerGoal); + addBullet(lines, "内在需求", character.innerNeed); + addBullet(lines, "恐惧 / 弱点", character.fear); + addBullet(lines, "秘密", character.secret); + addParagraph(lines, "性格", character.personality, 5); + addParagraph(lines, "背景", character.background, 5); + addParagraph(lines, "成长线", character.development, 5); + addParagraph(lines, "外貌与气质", character.appearance ?? character.presenceImpression ?? null, 5); + } + } + if (section.relations.length > 0) { + lines.push("### 角色关系"); + lines.push(""); + for (const relation of section.relations) { + const names = [ + relation.sourceCharacterName ?? relation.sourceCharacterId, + relation.targetCharacterName ?? relation.targetCharacterId, + ].filter(Boolean).join(" -> "); + lines.push(`- ${names}:${relation.surfaceRelation}`); + if (relation.hiddenTension) { + lines.push(` 隐性张力:${relation.hiddenTension}`); + } + } + lines.push(""); + } + return lines; +} + +function buildSetupSectionSummary(scope: NovelExportSectionScope, payload: SetupBundlePayload): string[] { + switch (scope) { + case "basic": + return buildBasicSummary(payload.basic as NovelExportBasicSection); + case "story_macro": + return buildStoryMacroSummary(payload.story_macro); + case "character": + return buildSetupCharacterSummary(payload.character); + default: + return []; + } +} + +function pickDefined>(input: T): Partial { + return Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined), + ) as Partial; +} + +function buildSetupBundlePayload(bundle: NovelExportBundle): SetupBundlePayload { + const novel = bundle.sections.basic.novel; + const setupCharacters = bundle.sections.character.characters.map((character) => pickDefined({ + id: character.id, + name: character.name, + role: character.role, + gender: character.gender, + castRole: character.castRole, + storyFunction: character.storyFunction, + relationToProtagonist: character.relationToProtagonist, + personality: character.personality, + background: character.background, + development: character.development, + outerGoal: character.outerGoal, + innerNeed: character.innerNeed, + fear: character.fear, + wound: character.wound, + misbelief: character.misbelief, + secret: character.secret, + moralLine: character.moralLine, + firstImpression: character.firstImpression, + appearance: character.appearance, + physique: character.physique, + attireStyle: character.attireStyle, + signatureDetail: character.signatureDetail, + voiceTexture: character.voiceTexture, + presenceImpression: character.presenceImpression, + arcStart: character.arcStart, + arcMidpoint: character.arcMidpoint, + arcClimax: character.arcClimax, + arcEnd: character.arcEnd, + })) as SetupBundleCharacter[]; + + return { + basic: { + novel: pickDefined({ + id: novel.id, + title: novel.title, + description: novel.description, + targetAudience: novel.targetAudience, + bookSellingPoint: novel.bookSellingPoint, + competingFeel: novel.competingFeel, + first30ChapterPromise: novel.first30ChapterPromise, + commercialTags: novel.commercialTags, + status: novel.status, + writingMode: novel.writingMode, + projectMode: novel.projectMode, + narrativePov: novel.narrativePov, + pacePreference: novel.pacePreference, + styleTone: novel.styleTone, + emotionIntensity: novel.emotionIntensity, + aiFreedom: novel.aiFreedom, + defaultChapterLength: novel.defaultChapterLength, + estimatedChapterCount: novel.estimatedChapterCount, + genre: novel.genre, + primaryStoryMode: novel.primaryStoryMode, + secondaryStoryMode: novel.secondaryStoryMode, + world: novel.world, + createdAt: novel.createdAt, + updatedAt: novel.updatedAt, + }) as SetupBundleBasicSection["novel"], + worldSlice: null, + }, + story_macro: bundle.sections.story_macro, + character: { + characters: setupCharacters, + relations: bundle.sections.character.relations, + castOptions: [], + }, + }; +} + export function buildScopedNovelExportPayload( bundle: NovelExportBundle, scope: NovelExportScope, @@ -205,8 +418,19 @@ export function buildScopedNovelExportPayload( scope: NovelExportScope; scopeLabel: string; }; - data: NovelExportSectionMap | NovelExportSectionMap[NovelExportSectionScope]; + data: NovelExportSectionMap | NovelExportSectionMap[NovelExportSectionScope] | SetupBundlePayload; } { + if (scope === "setup_bundle") { + return { + metadata: { + ...bundle.metadata, + scope, + scopeLabel: NOVEL_EXPORT_SCOPE_LABELS[scope], + }, + data: buildSetupBundlePayload(bundle), + }; + } + return { metadata: { ...bundle.metadata, @@ -220,7 +444,12 @@ export function buildScopedNovelExportPayload( export function buildMarkdownExportContent(bundle: NovelExportBundle, scope: NovelExportScope): string { const lines: string[] = []; const scopeLabel = NOVEL_EXPORT_SCOPE_LABELS[scope]; - const sectionScopes = scope === "full" ? FULL_SECTION_ORDER : [scope]; + const sectionScopes = scope === "full" + ? FULL_SECTION_ORDER + : scope === "setup_bundle" + ? SETUP_BUNDLE_SECTION_ORDER + : [scope]; + const setupBundlePayload = scope === "setup_bundle" ? buildSetupBundlePayload(bundle) : null; lines.push(`# ${bundle.metadata.novelTitle} 导出`); lines.push(""); @@ -230,17 +459,23 @@ export function buildMarkdownExportContent(bundle: NovelExportBundle, scope: Nov lines.push(""); for (const sectionScope of sectionScopes) { - const section = bundle.sections[sectionScope]; + const section = setupBundlePayload && sectionScope in setupBundlePayload + ? setupBundlePayload[sectionScope as keyof SetupBundlePayload] + : bundle.sections[sectionScope]; lines.push(`## ${NOVEL_EXPORT_SCOPE_LABELS[sectionScope]}`); lines.push(""); - const summaryLines = buildSectionSummary(sectionScope, section); + const summaryLines = setupBundlePayload + ? buildSetupSectionSummary(sectionScope, setupBundlePayload) + : buildSectionSummary(sectionScope, section as NovelExportSectionMap[NovelExportSectionScope]); if (summaryLines.length > 0) { lines.push(...summaryLines); } else { lines.push("(当前没有可总结的结构化内容)"); lines.push(""); } - addJsonBlock(lines, "完整数据", section); + if (scope !== "setup_bundle") { + addJsonBlock(lines, "完整数据", section); + } } return lines.join("\n"); diff --git a/server/src/services/novel/export/novelExportTypes.ts b/server/src/services/novel/export/novelExportTypes.ts index 21cfab82c..8b0f2f16e 100644 --- a/server/src/services/novel/export/novelExportTypes.ts +++ b/server/src/services/novel/export/novelExportTypes.ts @@ -201,7 +201,7 @@ export interface NovelExportSectionMap { pipeline: NovelExportPipelineSection; } -export type NovelExportSectionScope = Exclude; +export type NovelExportSectionScope = Exclude; export interface NovelExportBundle { metadata: { diff --git a/server/tests/novelExportService.test.js b/server/tests/novelExportService.test.js index 1b5c8ffd2..0837aa223 100644 --- a/server/tests/novelExportService.test.js +++ b/server/tests/novelExportService.test.js @@ -30,3 +30,128 @@ test("buildExportContent uses novel title plus timestamp as export filename", as prisma.novel.findUnique = originalFindUnique; } }); + +test("buildExportContent can export the portable novel setup bundle", async () => { + const service = new NovelExportService(); + service.buildExportBundle = async () => ({ + metadata: { + exportedAt: "2026-05-18T00:00:00.000Z", + novelId: "novel_setup_demo", + novelTitle: "可搬走的设定", + }, + sections: { + basic: { + novel: { + id: "novel_setup_demo", + title: "可搬走的设定", + writingMode: "ai_assisted", + projectMode: null, + genre: null, + primaryStoryMode: null, + secondaryStoryMode: null, + world: null, + estimatedChapterCount: 60, + commercialTags: [], + description: "一个能带去别处继续写的项目。", + outline: "后期卷规划不应进入初期设定导出。", + structuredOutline: "后期拆章不应进入初期设定导出。", + chapters: [ + { + id: "chapter_1", + title: "第一章", + order: 1, + content: "已经生成的正文不应进入初期设定导出。", + novelId: "novel_setup_demo", + createdAt: "2026-05-18T00:00:00.000Z", + updatedAt: "2026-05-18T00:00:00.000Z", + }, + ], + characters: [], + }, + worldSlice: null, + }, + story_macro: { + storyMacroPlan: { + storyInput: "少年进入雾城。", + expansion: null, + decomposition: null, + }, + bookContract: null, + }, + character: { + characters: [ + { + id: "char_1", + name: "林川", + role: "主角", + castRole: "protagonist", + }, + ], + relations: [], + castOptions: [], + timelines: [ + { + characterId: "char_1", + characterName: "林川", + events: [ + { + id: "timeline_1", + novelId: "novel_setup_demo", + characterId: "char_1", + chapterId: "chapter_1", + chapterOrder: 1, + title: "正文后的变化", + content: "后期角色状态不应进入初期设定导出。", + source: "chapter", + createdAt: "2026-05-18T00:00:00.000Z", + updatedAt: "2026-05-18T00:00:00.000Z", + }, + ], + }, + ], + }, + outline: { workspace: null }, + structured: { workspace: null }, + chapter: { + chapters: [], + chapterPlans: [], + latestStateSnapshot: null, + }, + pipeline: { + latestPipelineJob: null, + qualityReport: { + summary: { overall: 0 }, + totalReports: 0, + chapterReports: [], + }, + bible: null, + plotBeats: [], + payoffLedger: null, + latestStateSnapshot: null, + chapterAuditReports: [], + }, + }, + }); + + const result = await service.buildExportContent("novel_setup_demo", "markdown", "setup_bundle"); + + assert.match(result.fileName, /^可搬走的设定-setup_bundle-\d{8}-\d{6}\.md$/); + assert.match(result.content, /导出范围:小说设定/); + assert.match(result.content, /## 项目设定/); + assert.match(result.content, /## 故事宏观规划/); + assert.match(result.content, /## 角色准备/); + assert.doesNotMatch(result.content, /## 卷战略 \/ 卷骨架/); + assert.doesNotMatch(result.content, /## 章节执行/); + assert.doesNotMatch(result.content, /完整数据/); + assert.doesNotMatch(result.content, /已经生成的正文不应进入初期设定导出/); + assert.doesNotMatch(result.content, /后期角色状态不应进入初期设定导出/); + + const jsonResult = await service.buildExportContent("novel_setup_demo", "json", "setup_bundle"); + const jsonPayload = JSON.parse(jsonResult.content); + assert.equal(jsonPayload.metadata.scope, "setup_bundle"); + assert.deepEqual(Object.keys(jsonPayload.data), ["basic", "story_macro", "character"]); + assert.equal(jsonPayload.data.basic.novel.chapters, undefined); + assert.equal(jsonPayload.data.basic.novel.outline, undefined); + assert.equal(jsonPayload.data.basic.novel.structuredOutline, undefined); + assert.equal(jsonPayload.data.character.timelines, undefined); +}); diff --git a/shared/types/novelExport.ts b/shared/types/novelExport.ts index 32b848910..54f2678c8 100644 --- a/shared/types/novelExport.ts +++ b/shared/types/novelExport.ts @@ -1,5 +1,6 @@ export const NOVEL_EXPORT_SCOPE_VALUES = [ "full", + "setup_bundle", "basic", "story_macro", "character", @@ -21,6 +22,7 @@ export type NovelExportDownloadFormat = (typeof NOVEL_EXPORT_DOWNLOAD_FORMAT_VAL export const NOVEL_EXPORT_SCOPE_LABELS: Record = { full: "整本书", + setup_bundle: "小说设定", basic: "项目设定", story_macro: "故事宏观规划", character: "角色准备", diff --git "a/\345\220\257\345\212\250.command" "b/\345\220\257\345\212\250.command" new file mode 100755 index 000000000..aef14baf9 --- /dev/null +++ "b/\345\220\257\345\212\250.command" @@ -0,0 +1,14 @@ +#!/bin/zsh + +DIR="$(cd "$(dirname "$0")" && pwd)" + +"$DIR/mac/start-mac.sh" +STATUS=$? + +if [ "$STATUS" -ne 0 ]; then + echo "" + echo "启动没有完成。按任意键关闭这个窗口。" + read -k 1 +fi + +exit "$STATUS"