From 584e206e48b4ad32c21b67eaffbcc50dadb27ec0 Mon Sep 17 00:00:00 2001
From: YuanShiJiLoong <2450829984@qq.com>
Date: Wed, 4 Mar 2026 01:23:08 +0900
Subject: [PATCH] feat: v1.1.11 - bookInfo per-work isolation, provider-aware
token estimation, Docker support
---
.dockerignore | 15 ++++++++
.github/workflows/docker-publish.yml | 46 +++++++++++++++++++++++++
Dockerfile | 38 +++++++++++++++++++++
app/components/SettingsPanel.js | 17 +++++++---
app/components/Sidebar.js | 3 +-
app/lib/context-engine.js | 51 ++++++++++++++++++++++++----
app/lib/settings.js | 38 +++++++++++++++++++++
docker-compose.yml | 29 ++++++++++++++++
package.json | 4 +--
9 files changed, 227 insertions(+), 14 deletions(-)
create mode 100644 .dockerignore
create mode 100644 .github/workflows/docker-publish.yml
create mode 100644 Dockerfile
create mode 100644 docker-compose.yml
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a8e3b97
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+node_modules
+.next
+dist
+.git
+.gitignore
+*.md
+.env
+.env.local
+.env.*.local
+electron
+afterPack.js
+.vscode
+.idea
+.antigravityignore
+.agents
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..d3175dd
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,46 @@
+# ============================================
+# 自动构建并发布 Docker 镜像到 Docker Hub
+# ============================================
+# 触发条件: 发布 Release 或手动触发
+# 需要在 GitHub Secrets 中配置:
+# - DOCKERHUB_USERNAME
+# - DOCKERHUB_TOKEN
+# ============================================
+
+name: Docker Publish
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Extract version from package.json
+ id: version
+ run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: |
+ yuanshijiloong/author:latest
+ yuanshijiloong/author:${{ steps.version.outputs.VERSION }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b178599
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+# ============================================
+# Author App — Docker 多阶段构建
+# ============================================
+# 用法:
+# docker compose up -d # 启动
+# docker compose down # 停止
+# docker compose up -d --build # 重新构建
+# ============================================
+
+# ---- 阶段1: 安装依赖 ----
+FROM node:20-alpine AS deps
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm ci --omit=dev
+
+# ---- 阶段2: 构建 ----
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# ---- 阶段3: 生产运行 ----
+FROM node:20-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV HOSTNAME="0.0.0.0"
+
+# 从构建产物复制 standalone + static + public
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+
+EXPOSE 3000
+
+CMD ["node", "server.js"]
diff --git a/app/components/SettingsPanel.js b/app/components/SettingsPanel.js
index 1cbe11c..ea60d3e 100644
--- a/app/components/SettingsPanel.js
+++ b/app/components/SettingsPanel.js
@@ -276,7 +276,6 @@ export default function SettingsPanel() {
workName: workNode.name,
exportedAt: new Date().toISOString(),
nodes: exportNodes,
- bookInfo: projectSettings.bookInfo || {},
writingMode: projectSettings.writingMode || 'webnovel',
};
await downloadFile(JSON.stringify(data, null, 2), `${baseName}-设定集.json`, 'application/json');
@@ -315,13 +314,23 @@ export default function SettingsPanel() {
if (!workNode) { alert(t('settings.importNoWork')); return; }
const restorePS = () => {
- if (data.bookInfo || data.writingMode) {
+ if (data.writingMode) {
const ps = getProjectSettings();
- if (data.bookInfo) ps.bookInfo = data.bookInfo;
if (data.writingMode) ps.writingMode = data.writingMode;
saveProjectSettings(ps); setSettings(ps);
if (data.writingMode) { setWritingModeState(data.writingMode); setWritingMode(data.writingMode); }
}
+ // 兼容旧版导出:如果 JSON 中有 bookInfo,写入作品的 bookInfo 节点
+ if (data.bookInfo && Object.values(data.bookInfo).some(v => v)) {
+ const importedNodes = data.nodes;
+ const importedWork = importedNodes.find(n => n.type === 'work');
+ if (importedWork) {
+ const biNode = importedNodes.find(n => n.parentId === importedWork.id && n.category === 'bookInfo');
+ if (biNode && (!biNode.content || Object.keys(biNode.content).length === 0)) {
+ biNode.content = data.bookInfo;
+ }
+ }
+ }
};
const existingWork = nodes.find(n => n.type === 'work' && n.name === workNode.name);
if (existingWork) {
@@ -748,7 +757,7 @@ export default function SettingsPanel() {
{showBookInfo ? (
- handleSettingsSave('bookInfo', data)} />
+ handleUpdateNode(selectedNode.id, { content: data })} />
) : (
{ch.title}
{(ch.wordCount || 0) > 0 && (
- {ch.wordCount.toLocaleString()}字 · ~{Math.ceil((ch.wordCount || 0) * 1.5).toLocaleString()} tokens
+ {ch.wordCount.toLocaleString()}字 · ~{estimateTokens((ch.content || '').replace(/<[^>]*>/g, '')).toLocaleString()} tokens
)}
diff --git a/app/lib/context-engine.js b/app/lib/context-engine.js
index 058111e..570a4c3 100644
--- a/app/lib/context-engine.js
+++ b/app/lib/context-engine.js
@@ -10,12 +10,44 @@ import { getEmbedding, cosineSimilarity } from './embeddings';
export const INPUT_TOKEN_BUDGET = 200000; // 输入预算(发送给AI的上下文)
export const OUTPUT_TOKEN_BUDGET = 6000; // 输出预算(AI生成的回复长度,约4500字)
-// 中文 token 估算:约 1.5 个中文字符 = 1 个 token
+// 各 provider 的中文 token 比例(中文字符数 / token 数)
+// 比例越大 = tokenizer 越高效 = 同样文字消耗越少 token
+const PROVIDER_CN_RATIO = {
+ // 国产模型 — tokenizer 对中文优化,约 2.5~3 字/token
+ zhipu: 2.5,
+ deepseek: 2.5,
+ siliconflow: 2.5,
+ volcengine: 2.5,
+ moonshot: 2.5,
+ // 西方模型 — 中文效率较低
+ openai: 1.5,
+ 'openai-responses': 1.5,
+ claude: 1.8,
+ // Gemini — 中间水平
+ gemini: 2.0,
+ 'gemini-native': 2.0,
+ // 默认 / 自定义
+ custom: 2.0,
+};
+
+// 获取当前 provider 的中文 token 比例
+function getCnRatio() {
+ try {
+ const settings = getProjectSettings();
+ const provider = settings.apiConfig?.provider || 'custom';
+ return PROVIDER_CN_RATIO[provider] || 2.0;
+ } catch {
+ return 2.0;
+ }
+}
+
+// 基于当前 provider 的 token 估算
export function estimateTokens(text) {
if (!text) return 0;
+ const cnRatio = getCnRatio();
const chineseChars = (text.match(/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/g) || []).length;
const otherChars = text.length - chineseChars;
- return Math.ceil(chineseChars / 1.5 + otherChars / 4);
+ return Math.ceil(chineseChars / cnRatio + otherChars / 4);
}
// 上下文模块优先级(数字越小越优先)
@@ -103,8 +135,9 @@ export async function getContextItems(activeChapterId) {
}
}
- // 作品信息
- const bookInfo = settings.bookInfo;
+ // 作品信息 — 从当前作品的 bookInfo 特殊节点读取
+ const bookInfoNode = nodes.find(n => n.category === 'bookInfo' && n.type === 'special');
+ const bookInfo = bookInfoNode?.content || {};
if (bookInfo && (bookInfo.title || bookInfo.author || bookInfo.genre || bookInfo.synopsis)) {
items.push({
id: 'bookinfo',
@@ -231,9 +264,13 @@ export async function buildContext(activeChapterId, selectedText, selectedIds =
const writingMode = getWritingMode();
+ // 从当前作品的 bookInfo 特殊节点读取
+ const bookInfoNode = nodes.find(n => n.category === 'bookInfo' && n.type === 'special');
+ const bookInfoData = bookInfoNode?.content || {};
+
// 先构建各模块的原始文本
const rawModules = {
- bookInfo: (!selectedIds || selectedIds.has('bookinfo')) ? buildBookInfoContext(settings.bookInfo) : '',
+ bookInfo: (!selectedIds || selectedIds.has('bookinfo')) ? buildBookInfoContext(bookInfoData) : '',
characters: buildCharactersContext(finalItemNodes.filter(n => n.category === 'character')),
locations: buildLocationsContext(finalItemNodes.filter(n => n.category === 'location'), nodes),
worldbuilding: buildWorldContext(finalItemNodes.filter(n => n.category === 'world'), nodes),
@@ -375,9 +412,9 @@ export async function getContextPreview(activeChapterId, selectedText) {
{
key: 'bookInfo',
label: '📖 作品信息',
- count: settings.bookInfo?.title ? 1 : 0,
+ count: (() => { const bi = nodes.find(n => n.category === 'bookInfo' && n.type === 'special'); return bi?.content?.title ? 1 : 0; })(),
totalCount: 1,
- tokens: estimateTokens(buildBookInfoContext(settings.bookInfo)),
+ tokens: (() => { const bi = nodes.find(n => n.category === 'bookInfo' && n.type === 'special'); return estimateTokens(buildBookInfoContext(bi?.content || {})); })(),
priority: PRIORITY.bookInfo,
},
{
diff --git a/app/lib/settings.js b/app/lib/settings.js
index 6a4084b..8322558 100644
--- a/app/lib/settings.js
+++ b/app/lib/settings.js
@@ -390,6 +390,7 @@ export async function getSettingsNodes() {
nodes = await migrateToWorkStructure(nodes);
nodes = await migrateGlobalRulesToWork(nodes);
nodes = await ensureWorkExists(nodes);
+ nodes = await migrateBookInfoToNode(nodes);
return nodes;
} catch {
return getDefaultNodes();
@@ -661,6 +662,43 @@ export async function getNodePath(id) {
return path;
}
+// ==================== bookInfo 迁移到作品节点 ====================
+
+/**
+ * 将全局 settings.bookInfo 迁移到默认作品的 bookInfo 节点 content 中
+ * 只执行一次:检查全局 bookInfo 是否有内容,迁移后清空
+ */
+async function migrateBookInfoToNode(nodes) {
+ if (typeof window === 'undefined') return nodes;
+ try {
+ const settings = getProjectSettings();
+ const bi = settings.bookInfo;
+ // 检查是否有需要迁移的全局 bookInfo 数据
+ if (!bi || !Object.values(bi).some(v => v)) return nodes;
+
+ // 找到当前活动作品(或默认作品)的 bookInfo 节点
+ const activeWid = getActiveWorkId();
+ const targetWorkId = activeWid || nodes.find(n => n.type === 'work')?.id;
+ if (!targetWorkId) return nodes;
+
+ const biNode = nodes.find(n => n.parentId === targetWorkId && n.category === 'bookInfo' && n.type === 'special');
+ if (!biNode) return nodes;
+
+ // 只在节点内容为空时迁移(避免覆盖已有数据)
+ if (!biNode.content || Object.keys(biNode.content).length === 0) {
+ biNode.content = { ...bi };
+ await saveSettingsNodes(nodes);
+ }
+
+ // 清空全局 bookInfo,防止重复迁移
+ settings.bookInfo = {};
+ saveProjectSettings(settings);
+ } catch (e) {
+ console.warn('[Settings] bookInfo migration failed:', e);
+ }
+ return nodes;
+}
+
// ==================== 旧数据迁移 ====================
// ==================== 旧数据迁移 ====================
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..9ad6356
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,29 @@
+# ============================================
+# Author App — Docker Compose
+# ============================================
+#
+# 快速开始:
+# 1. 复制 .env.example 为 .env(可选,配置 API Key)
+# 2. docker compose up -d
+# 3. 浏览器访问 http://localhost:3000
+#
+# 更新:
+# docker compose down && docker compose up -d --build
+#
+# ============================================
+
+services:
+ author-app:
+ build: .
+ container_name: author-studio
+ ports:
+ - "${PORT:-3000}:3000"
+ env_file:
+ - path: .env
+ required: false
+ - path: .env.local
+ required: false
+ environment:
+ - NODE_ENV=production
+ - HOSTNAME=0.0.0.0
+ restart: unless-stopped
diff --git a/package.json b/package.json
index eb10dd5..68833de 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "author-app",
- "version": "1.1.10",
+ "version": "1.1.11",
"private": true,
"main": "electron/main.js",
"scripts": {
@@ -108,4 +108,4 @@
"createStartMenuShortcut": true
}
}
-}
+}
\ No newline at end of file