feat: v1.1.11 - bookInfo per-work isolation, provider-aware token estimation, Docker support
This commit is contained in:
1 parent
94d252ad0c
commit
584e206e48
9 files changed
+227
-14
No files matched your search
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
electron
|
||||
afterPack.js
|
||||
.vscode
|
||||
.idea
|
||||
.antigravityignore
|
||||
.agents
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ==================== 旧数据迁移 ====================
|
||||
|
||||
// ==================== 旧数据迁移 ====================
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user