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