feat: v1.1.11 - bookInfo per-work isolation, provider-aware token estimation, Docker support

This commit is contained in:
YuanShiJiLoong committed 2026-03-04 01:23:33 +09:00
1 parent 94d252ad0c
commit 584e206e48
9 files changed
+227 -14

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
node_modules
.next
dist
.git
.gitignore
*.md
.env
.env.local
.env.*.local
electron
afterPack.js
.vscode
.idea
.antigravityignore
.agents
+46
View File
@@ -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
+38
View File
@@ -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"]
+13 -4
View File
@@ -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() {
<div style={{ flex: 1, overflow: 'auto' }}>
{showBookInfo ? (
<div style={{ padding: '20px 24px' }}>
<BookInfoForm data={settings.bookInfo} onChange={data => handleSettingsSave('bookInfo', data)} />
<BookInfoForm data={selectedNode.content || {}} onChange={data => handleUpdateNode(selectedNode.id, { content: data })} />
</div>
) : (
<SettingsItemEditor
+2 -1
View File
@@ -7,6 +7,7 @@ import { createChapter, deleteChapter, updateChapter, saveChapters, getChapters
import { exportProject, importProject, importWork, exportWorkAsTxt, exportWorkAsMarkdown, exportWorkAsDocx, exportWorkAsEpub, exportWorkAsPdf } from '../lib/project-io';
import { WRITING_MODES, getAllWorks, getSettingsNodes, createWorkNode, saveSettingsNodes, setActiveWorkId as setActiveWorkIdSetting } from '../lib/settings';
import { detectConflicts, mergeChapters } from '../lib/chapter-number';
import { estimateTokens } from '../lib/context-engine';
export default function Sidebar({ onOpenHelp, onToggle, editorRef }) {
const {
@@ -331,7 +332,7 @@ export default function Sidebar({ onOpenHelp, onToggle, editorRef }) {
<span className="gdocs-tab-title">{ch.title}</span>
{(ch.wordCount || 0) > 0 && (
<span style={{ display: 'block', fontSize: '10px', color: 'var(--text-muted)', marginTop: '1px' }}>
{ch.wordCount.toLocaleString()}字 · ~{Math.ceil((ch.wordCount || 0) * 1.5).toLocaleString()} tokens
{ch.wordCount.toLocaleString()}字 · ~{estimateTokens((ch.content || '').replace(/<[^>]*>/g, '')).toLocaleString()} tokens
</span>
)}
</span>
+44 -7
View File
@@ -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,
},
{
+38
View File
@@ -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;
}
// ==================== 旧数据迁移 ====================
// ==================== 旧数据迁移 ====================
+29
View File
@@ -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
+2 -2
View File
@@ -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
}
}
}
}