导出整本书
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"