Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README-MAC.md
Original file line number Diff line number Diff line change
@@ -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/`。
23 changes: 22 additions & 1 deletion client/src/pages/novels/NovelEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 : "导出失败。");
Expand Down Expand Up @@ -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";
Expand All @@ -2543,6 +2555,8 @@ export default function NovelEdit() {
canExportCurrentStep: Boolean(currentExportScope),
isExportingCurrentMarkdown,
isExportingCurrentJson,
isExportingSetupBundleMarkdown,
isExportingSetupBundleJson,
isExportingFullMarkdown,
isExportingFullJson,
onExportCurrent: (format) => {
Expand All @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions client/src/pages/novels/components/NovelEditView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,10 @@ function DesktopNovelEditView(props: NovelEditViewProps) {
<DialogHeader>
<DialogTitle>导出项目内容</DialogTitle>
<DialogDescription>
当前步骤会按你正在查看的工作台导出;整本书会把项目设定、故事规划、角色、卷规划、拆章、章节和质量修复资产一起导出
小说设定会包含项目设定、故事规划和角色准备,适合带去别处继续生成正文
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-base">当前步骤:{currentStepLabel}</CardTitle>
Expand All @@ -221,6 +221,27 @@ function DesktopNovelEditView(props: NovelEditViewProps) {
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">小说设定</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => exportControls.onExportSetupBundle("markdown")}
disabled={exportControls.isExportingSetupBundleMarkdown}
>
{exportControls.isExportingSetupBundleMarkdown ? "导出中..." : "Markdown"}
</Button>
<Button
variant="outline"
onClick={() => exportControls.onExportSetupBundle("json")}
disabled={exportControls.isExportingSetupBundleJson}
>
{exportControls.isExportingSetupBundleJson ? "导出中..." : "JSON"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">整本书</CardTitle>
Expand Down
3 changes: 3 additions & 0 deletions client/src/pages/novels/components/NovelEditView.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 23 additions & 1 deletion client/src/pages/novels/mobile/MobileNovelEditView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export default function MobileNovelEditView(props: NovelEditViewProps) {
<DialogContent className="max-h-[88vh] w-[calc(100vw-1.5rem)] overflow-y-auto rounded-2xl">
<DialogHeader>
<DialogTitle>创作工具</DialogTitle>
<DialogDescription>查看任务进度,导出当前步骤或整本书内容。</DialogDescription>
<DialogDescription>查看任务进度,导出设定或正文内容。</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-3 gap-2">
Expand Down Expand Up @@ -185,6 +185,28 @@ export default function MobileNovelEditView(props: NovelEditViewProps) {
</div>
</div>

<div className="rounded-xl border border-border/70 p-3">
<div className="text-sm font-medium">导出小说设定</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
onClick={() => exportControls.onExportSetupBundle("markdown")}
disabled={exportControls.isExportingSetupBundleMarkdown}
>
{exportControls.isExportingSetupBundleMarkdown ? "导出中..." : "Markdown"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => exportControls.onExportSetupBundle("json")}
disabled={exportControls.isExportingSetupBundleJson}
>
{exportControls.isExportingSetupBundleJson ? "导出中..." : "JSON"}
</Button>
</div>
</div>

<div className="rounded-xl border border-border/70 p-3">
<div className="text-sm font-medium">导出整本书</div>
<div className="mt-3 grid grid-cols-2 gap-2">
Expand Down
Binary file added desktop/builder/app-icon.icns
Binary file not shown.
26 changes: 21 additions & 5 deletions desktop/electron-builder.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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: [
Expand All @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions desktop/scripts/generate-desktop-icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")


Expand Down
6 changes: 4 additions & 2 deletions desktop/src/runtime/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions mac/start-mac.sh
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion scripts/wait-for-port.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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) {
Expand Down
Loading