v1.1.12: add API balance query, persistence improvements, Docker enhancements

This commit is contained in:
YuanShiJiLoong committed 2026-03-04 16:37:12 +09:00
1 parent 584e206e48
commit 7f7dc609b7
30 files changed
+1300 -139

No files matched your search

+1
View File
@@ -13,3 +13,4 @@ afterPack.js
.idea
.antigravityignore
.agents
data
BIN
View File
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
# 将 author.example.com 替换为你的域名
# Replace author.example.com with your domain
author.example.com {
reverse_proxy author-app:3000
}
+119
View File
@@ -0,0 +1,119 @@
# 🐳 Docker 部署指南
## 快速开始
### 方式一:Docker Hub 拉取(推荐)
```bash
# 1. 创建项目目录
mkdir author && cd author
# 2. 创建 docker-compose.yml
cat > docker-compose.yml << 'EOF'
services:
author-app:
image: yuanshijiloong/author:latest
container_name: author-studio
ports:
- "3000:3000"
env_file:
- path: .env
required: false
restart: unless-stopped
EOF
# 3. 启动
docker compose up -d
# 4. 访问 http://localhost:3000
```
### 方式二:源码构建
```bash
# 1. 克隆仓库
git clone https://github.com/YuanShiJiLoong/author.git
cd author
# 2. 构建并启动
docker compose up -d --build
# 3. 访问 http://localhost:3000
```
## 配置 API Key
有两种方式配置:
### 方式 A:应用内配置(最简单)
启动后直接在应用内 ⚙️ 设置中填写 API Key,无需任何额外配置。
### 方式 B:环境变量配置
```bash
# 复制模板
cp .env.example .env
# 编辑 .env,填入你的 Key
# 例如使用智谱AI:
# API_KEY=你的Key
# 重启生效
docker compose restart
```
## 自定义端口
```bash
# 方式1:修改 docker-compose.yml 中的端口映射
ports:
- "8080:3000" # 改为 8080
# 方式2:通过环境变量
PORT=8080 docker compose up -d
```
## 更新
### Docker Hub 拉取方式
```bash
docker compose down
docker compose pull
docker compose up -d
```
### 源码构建方式
```bash
git pull
docker compose down
docker compose up -d --build
```
## 反向代理(去掉端口号 + 自动 HTTPS)
如果你有域名,可以用 Caddy 反向代理,实现 `https://你的域名` 直接访问,不需要端口号:
```bash
# 1. 编辑 Caddyfile,将 author.example.com 替换为你的域名
nano Caddyfile
# 2. 使用带 Caddy 的 compose 文件启动
docker compose -f docker-compose.caddy.yml up -d
# 3. 访问 https://你的域名(自动签发 SSL 证书)
```
> ⚠️ 确保域名已解析到服务器 IP,且服务器 80/443 端口未被占用。
## 常见问题
### Q: 数据存储在哪里?
A: 数据存储在浏览器的 IndexedDB 和 localStorage 中,与容器无关。清除浏览器数据会丢失内容,重建容器不会。
### Q: 可以在手机/平板上使用吗?
A: 可以。部署到服务器后,在同一局域网内用手机浏览器访问 `http://服务器IP:3000` 即可。
### Q: 支持 HTTPS 吗?
A: Author 本身不内置 HTTPS。建议在前面加一层反向代理(如 Nginx、Caddy 或 Traefik),由反向代理处理 SSL 证书。
### Q: Docker Desktop 支持 Windows 吗?
A: 支持 Windows 10/11,需要启用 WSL2 或 Hyper-V。安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/) 后即可使用。
+121
View File
@@ -0,0 +1,121 @@
# 🐳 Docker Deployment Guide
## Quick Start
### Option 1: Pull from Docker Hub (Recommended)
```bash
# 1. Create project directory
mkdir author && cd author
# 2. Create docker-compose.yml
cat > docker-compose.yml << 'EOF'
services:
author-app:
image: yuanshijiloong/author:latest
container_name: author-studio
ports:
- "3000:3000"
env_file:
- path: .env
required: false
restart: unless-stopped
EOF
# 3. Start
docker compose up -d
# 4. Visit http://localhost:3000
```
### Option 2: Build from Source
```bash
# 1. Clone the repository
git clone https://github.com/YuanShiJiLoong/author.git
cd author
# 2. Build and start
docker compose up -d --build
# 3. Visit http://localhost:3000
```
## Configure API Key
Two ways to configure:
### Option A: In-app Settings (Easiest)
After starting, configure your API Key directly in the app's ⚙️ Settings. No extra configuration needed.
### Option B: Environment Variables
```bash
# Copy the template
cp .env.example .env
# Edit .env with your Key
# Example for OpenAI:
# API_KEY=your-key-here
# API_BASE_URL=https://api.openai.com/v1
# API_MODEL=gpt-4o
# Restart to apply
docker compose restart
```
## Custom Port
```bash
# Option 1: Modify port mapping in docker-compose.yml
ports:
- "8080:3000" # Change to 8080
# Option 2: Via environment variable
PORT=8080 docker compose up -d
```
## Updating
### Docker Hub Pull
```bash
docker compose down
docker compose pull
docker compose up -d
```
### Source Build
```bash
git pull
docker compose down
docker compose up -d --build
```
## Reverse Proxy (No Port Number + Auto HTTPS)
If you have a domain, use Caddy as a reverse proxy to access via `https://your-domain.com` without a port number:
```bash
# 1. Edit Caddyfile, replace author.example.com with your domain
nano Caddyfile
# 2. Start with the Caddy compose file
docker compose -f docker-compose.caddy.yml up -d
# 3. Visit https://your-domain (SSL certificate auto-provisioned)
```
> ⚠️ Make sure your domain points to the server IP and ports 80/443 are not in use.
## FAQ
### Q: Where is my data stored?
A: Data is stored in your browser's IndexedDB and localStorage, independent of the container. Clearing browser data will lose your content, but rebuilding the container will not.
### Q: Can I use it on mobile/tablet?
A: Yes. After deploying to a server, access `http://server-ip:3000` from any device's browser on the same network.
### Q: Does it support HTTPS?
A: Author does not include built-in HTTPS. Use a reverse proxy (Nginx, Caddy, or Traefik) in front to handle SSL certificates.
### Q: Does Docker Desktop work on Windows?
A: Yes, it supports Windows 10/11 with WSL2 or Hyper-V enabled. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) to get started.
+6
View File
@@ -33,6 +33,12 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# 创建数据持久化目录
RUN mkdir -p /app/data && chown -R node:node /app/data
VOLUME /app/data
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+70 -29
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
// 拉取 Gemini 可用模型列表
// 拉取 Gemini 可用模型列表(支持分页,兼容中转)
export async function POST(request) {
try {
const { apiKey, baseUrl } = await request.json();
@@ -13,41 +13,74 @@ export async function POST(request) {
}
const base = (baseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/$/, '');
const url = `${base}/models?key=${apiKey}`;
let allModels = [];
let pageToken = '';
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
// 循环分页拉取
do {
const url = `${base}/models?key=${apiKey}&pageSize=1000${pageToken ? `&pageToken=${pageToken}` : ''}`;
if (!response.ok) {
const errorText = await response.text();
console.error('拉取模型列表失败:', response.status, errorText);
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (response.status === 401 || response.status === 403) {
return NextResponse.json(
{ error: 'API Key 无效或无权限' },
{ status: 401 }
);
if (!response.ok) {
// 首页失败时,不带 pageSize 重试(有些中转不支持分页参数)
if (allModels.length === 0) {
const fallbackUrl = `${base}/models?key=${apiKey}`;
const fallbackRes = await fetch(fallbackUrl, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (!fallbackRes.ok) {
const errorText = await fallbackRes.text();
console.error('拉取模型列表失败:', fallbackRes.status, errorText);
if (fallbackRes.status === 401 || fallbackRes.status === 403) {
return NextResponse.json(
{ error: 'API Key 无效或无权限' },
{ status: 401 }
);
}
return NextResponse.json(
{ error: `拉取失败(${fallbackRes.status})` },
{ status: fallbackRes.status }
);
}
const fallbackData = await fallbackRes.json();
allModels = extractModelArray(fallbackData);
break;
}
break;
}
return NextResponse.json(
{ error: `拉取失败(${response.status})` },
{ status: response.status }
);
}
const data = await response.json();
const data = await response.json();
allModels = allModels.concat(extractModelArray(data));
pageToken = data.nextPageToken || '';
} while (pageToken);
// 过滤出支持 generateContent 的模型
const models = (data.models || [])
.filter(m => m.supportedGenerationMethods?.includes('generateContent'))
.map(m => ({
id: m.name?.replace('models/', '') || m.name,
displayName: m.displayName || m.name,
description: m.description || '',
inputTokenLimit: m.inputTokenLimit,
outputTokenLimit: m.outputTokenLimit,
}))
// 如果中转没有返回 supportedGenerationMethods,则保留全部模型
const hasCapabilityInfo = allModels.some(m => m.supportedGenerationMethods?.length > 0);
let models;
if (hasCapabilityInfo) {
models = allModels.filter(m =>
!m.supportedGenerationMethods ||
m.supportedGenerationMethods.includes('generateContent')
);
} else {
models = allModels;
}
models = models.map(m => ({
id: (m.name?.replace('models/', '') || m.id || m.name || '').trim(),
displayName: m.displayName || m.display_name || m.name?.replace('models/', '') || m.id || '',
description: m.description || '',
inputTokenLimit: m.inputTokenLimit,
outputTokenLimit: m.outputTokenLimit,
}))
.filter(m => m.id)
.sort((a, b) => a.id.localeCompare(b.id));
return NextResponse.json({ models });
@@ -60,3 +93,11 @@ export async function POST(request) {
);
}
}
// 从不同格式的响应中提取模型数组
function extractModelArray(data) {
if (Array.isArray(data.models)) return data.models;
if (Array.isArray(data.data)) return data.data;
if (Array.isArray(data)) return data;
return [];
}
+176 -24
View File
@@ -13,13 +13,13 @@ export async function POST(request) {
}
// Gemini 原生格式
if (provider === 'gemini-native') {
if (['gemini-native', 'custom-gemini'].includes(provider)) {
return await fetchGeminiModels(apiKey, baseUrl, embedOnly);
}
// Claude/Anthropic(无 /models 端点,返回预定义列表)
if (provider === 'claude') {
return fetchClaudeModels();
// Claude/Anthropic(多策略拉取)
if (['claude', 'custom-claude'].includes(provider)) {
return await fetchClaudeModels(apiKey, baseUrl);
}
// OpenAI 兼容格式(适用于所有其他供应商)
@@ -34,38 +34,89 @@ export async function POST(request) {
}
}
// Gemini 原生格式拉取模型
// Gemini 原生格式拉取模型 — 支持分页,兼容中转
async function fetchGeminiModels(apiKey, baseUrl, embedOnly) {
const base = (baseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/$/, '');
const url = `${base}/models?key=${apiKey}`;
let allModels = [];
let pageToken = '';
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
// 循环分页拉取
do {
const url = `${base}/models?key=${apiKey}&pageSize=1000${pageToken ? `&pageToken=${pageToken}` : ''}`;
if (!response.ok) {
return handleFetchError(response);
}
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
let models = (data.models || []);
if (!response.ok) {
// 分页请求失败时,尝试不带 pageSize 参数(有些中转不支持)
if (allModels.length === 0) {
const fallbackUrl = `${base}/models?key=${apiKey}`;
const fallbackRes = await fetch(fallbackUrl, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
if (!fallbackRes.ok) {
return handleFetchError(fallbackRes);
}
const fallbackData = await fallbackRes.json();
allModels = extractModelArray(fallbackData);
break;
}
break;
}
if (embedOnly) {
models = models.filter(m => m.supportedGenerationMethods?.includes('embedContent'));
} else {
models = models.filter(m => m.supportedGenerationMethods?.includes('generateContent') || m.supportedGenerationMethods?.includes('embedContent'));
const data = await response.json();
// 兼容不同中转返回格式:models[] 或 data[]
allModels = allModels.concat(extractModelArray(data));
pageToken = data.nextPageToken || '';
} while (pageToken);
let models = allModels;
// 过滤逻辑:有 supportedGenerationMethods 时按能力过滤,没有时全部保留
const hasCapabilityInfo = models.some(m => m.supportedGenerationMethods?.length > 0);
if (hasCapabilityInfo) {
if (embedOnly) {
models = models.filter(m => m.supportedGenerationMethods?.includes('embedContent'));
} else {
models = models.filter(m =>
!m.supportedGenerationMethods ||
m.supportedGenerationMethods.includes('generateContent') ||
m.supportedGenerationMethods.includes('embedContent')
);
}
} else if (embedOnly) {
// 无能力信息时,按名称匹配嵌入模型
models = models.filter(m => {
const id = (m.name || m.id || '').toLowerCase();
return /embed|text-embed/i.test(id);
});
}
models = models.map(m => ({
id: m.name?.replace('models/', '') || m.name,
displayName: m.displayName || m.name,
id: (m.name?.replace('models/', '') || m.id || m.name || '').trim(),
displayName: m.displayName || m.display_name || m.name?.replace('models/', '') || m.id || '',
}))
.filter(m => m.id) // 过滤掉空 ID
.sort((a, b) => a.id.localeCompare(b.id));
return NextResponse.json({ models });
}
// 从不同格式的响应中提取模型数组
function extractModelArray(data) {
// Gemini 原生格式: { models: [...] }
if (Array.isArray(data.models)) return data.models;
// OpenAI 兼容格式: { data: [...] }
if (Array.isArray(data.data)) return data.data;
// 直接是数组
if (Array.isArray(data)) return data;
return [];
}
// OpenAI 兼容格式拉取模型(/v1/models)
async function fetchOpenAIModels(apiKey, baseUrl, embedOnly) {
const base = (baseUrl || '').replace(/\/$/, '');
@@ -94,7 +145,8 @@ async function fetchOpenAIModels(apiKey, baseUrl, embedOnly) {
let models = (data.data || []);
if (embedOnly) {
models = models.filter(m => /embed/i.test(m.id));
// 匹配常见嵌入模型:embed*, bge-*, bce-*, e5-*, gte-* 等
models = models.filter(m => /embed|\/bge[-_]|\/bce[-_]|\/e5[-_]|\/gte[-_]|text-embedding/i.test(m.id));
}
models = models.map(m => ({
@@ -125,9 +177,38 @@ async function handleFetchError(response) {
);
}
// Claude/Anthropic 模型列表(预定义,无 API 端点)
function fetchClaudeModels() {
// Claude/Anthropic 模型列表 — 多策略拉取
// 策略1: Anthropic 原生 API(直连 api.anthropic.com)
// 策略2: OpenAI 兼容格式(中转/代理)
// 策略3: 预定义列表(兜底)
async function fetchClaudeModels(apiKey, baseUrl) {
const base = (baseUrl || 'https://api.anthropic.com').replace(/\/$/, '');
// 策略1: 尝试 Anthropic 原生 /v1/models
if (apiKey) {
try {
const models = await tryAnthropicNativeModels(apiKey, base);
if (models.length > 0) {
return NextResponse.json({ models });
}
} catch (err) {
console.warn('Claude native API failed:', err.message);
}
// 策略2: 尝试 OpenAI 兼容格式 /v1/models(很多中转用这种格式)
try {
const models = await tryOpenAICompatModels(apiKey, base);
if (models.length > 0) {
return NextResponse.json({ models });
}
} catch (err) {
console.warn('Claude OpenAI-compat API failed:', err.message);
}
}
// 策略3: 预定义列表
const models = [
{ id: 'claude-opus-4-20250514', displayName: 'Claude Opus 4' },
{ id: 'claude-sonnet-4-20250514', displayName: 'Claude Sonnet 4' },
{ id: 'claude-3-7-sonnet-20250219', displayName: 'Claude 3.7 Sonnet' },
{ id: 'claude-3-5-haiku-20241022', displayName: 'Claude 3.5 Haiku' },
@@ -138,3 +219,74 @@ function fetchClaudeModels() {
];
return NextResponse.json({ models });
}
// Anthropic 原生格式:x-api-key + anthropic-version header
async function tryAnthropicNativeModels(apiKey, base) {
let allModels = [];
let hasMore = true;
let afterId = null;
while (hasMore) {
let url = `${base}/v1/models?limit=100`;
if (afterId) url += `&after_id=${afterId}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
});
if (!response.ok) return [];
const data = await response.json();
const pageModels = data.data || [];
allModels = allModels.concat(pageModels);
hasMore = data.has_more === true;
if (hasMore && pageModels.length > 0) {
afterId = pageModels[pageModels.length - 1].id;
} else {
hasMore = false;
}
}
return allModels.map(m => ({
id: m.id,
displayName: m.display_name || m.id,
})).sort((a, b) => a.id.localeCompare(b.id));
}
// OpenAI 兼容格式:Bearer token + /v1/models(多数中转使用此格式)
async function tryOpenAICompatModels(apiKey, base) {
// 尝试 /v1/models 和 /models 两种路径
for (const path of ['/v1/models', '/models']) {
try {
const url = `${base}${path}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
});
if (!response.ok) continue;
const data = await response.json();
const rawModels = data.data || [];
if (rawModels.length === 0) continue;
return rawModels.map(m => ({
id: m.id,
displayName: m.id,
})).sort((a, b) => a.id.localeCompare(b.id));
} catch {
continue;
}
}
return [];
}
+2 -2
View File
@@ -14,7 +14,7 @@ export async function POST(request) {
}
// Gemini 原生格式的测试
if (provider === 'gemini-native') {
if (['gemini-native', 'custom-gemini'].includes(provider)) {
return await testGeminiNative(apiKey, baseUrl, model);
}
@@ -24,7 +24,7 @@ export async function POST(request) {
}
// Claude/Anthropic 格式的测试
if (provider === 'claude') {
if (['claude', 'custom-claude'].includes(provider)) {
return await testClaude(apiKey, baseUrl, model);
}
+268
View File
@@ -0,0 +1,268 @@
import { NextResponse } from 'next/server';
// ==================== API 余额查询 ====================
// 三层瀑布策略:
// 1. 通用计费接口(NewAPI/OneAPI/大部分中转)
// 2. 供应商专用接口(DeepSeek/SiliconFlow/OpenRouter/Moonshot)
// 3. 都失败 → 返回 unsupported
export async function POST(request) {
try {
const { provider, apiKey, baseUrl } = await request.json();
if (!apiKey) {
return NextResponse.json({ error: '未配置 API Key' }, { status: 400 });
}
const effectiveBaseUrl = (baseUrl || '').replace(/\/+$/, '');
// 有专用接口的已知供应商列表
const knownProviders = ['deepseek', 'siliconflow', 'openrouter', 'moonshot'];
const isKnown = knownProviders.includes(provider);
if (isKnown) {
// 已知供应商:专用接口优先(避免通用接口返回错误数据)
const providerResult = await tryProviderSpecific(provider, effectiveBaseUrl, apiKey);
if (providerResult) return NextResponse.json(providerResult);
}
// 通用接口:/dashboard/billing/subscription + /dashboard/billing/usage
// 覆盖 NewAPI / OneAPI / ChatGPT-Next-Web 等中转
const universalResult = await tryUniversalBilling(effectiveBaseUrl, apiKey);
if (universalResult) return NextResponse.json(universalResult);
// 未知供应商 + 自定义:尝试所有专用接口
if (!isKnown) {
const providerResult = await tryProviderSpecific(provider, effectiveBaseUrl, apiKey);
if (providerResult) return NextResponse.json(providerResult);
}
// 都失败
return NextResponse.json({
supported: false,
message: '当前供应商不支持余额查询',
});
} catch (error) {
console.error('Balance query error:', error);
return NextResponse.json({ error: error.message || '查询失败' }, { status: 500 });
}
}
// ==================== 1. 通用 NewAPI/OneAPI 计费接口 ====================
async function tryUniversalBilling(baseUrl, apiKey) {
if (!baseUrl) return null;
// 去掉可能的 /v1 后缀,因为 billing 接口在根路径下
const root = baseUrl.replace(/\/v1\/?$/, '');
try {
const headers = { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' };
// 并行请求 subscription 和 usage
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1).toISOString().slice(0, 10);
const [subRes, usageRes] = await Promise.all([
fetchWithTimeout(`${root}/dashboard/billing/subscription`, { headers }, 8000),
fetchWithTimeout(`${root}/dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`, { headers }, 8000),
]);
if (!subRes.ok && !usageRes.ok) return null;
let totalBalance = null;
let usedAmount = null;
let hardLimit = null;
let currency = 'USD';
if (subRes.ok) {
const sub = await subRes.json();
hardLimit = sub.hard_limit_usd ?? sub.system_hard_limit_usd ?? null;
// 有些中转直接返回 soft_limit_usd 作为余额
if (hardLimit === null) hardLimit = sub.soft_limit_usd ?? null;
}
if (usageRes.ok) {
const usage = await usageRes.json();
// total_usage 通常以美分为单位
usedAmount = usage.total_usage != null ? usage.total_usage / 100 : null;
}
if (hardLimit != null) {
totalBalance = usedAmount != null ? hardLimit - usedAmount : hardLimit;
return {
supported: true,
source: 'universal',
balance: round(totalBalance),
currency,
detail: {
total_limit: round(hardLimit),
used: usedAmount != null ? round(usedAmount) : null,
remaining: round(totalBalance),
},
};
}
return null;
} catch {
return null;
}
}
// ==================== 2. 供应商专用接口 ====================
async function tryProviderSpecific(provider, baseUrl, apiKey) {
const handlers = {
deepseek: tryDeepSeek,
siliconflow: trySiliconFlow,
openrouter: tryOpenRouter,
moonshot: tryMoonshot,
};
// 根据已知供应商直接尝试
const handler = handlers[provider];
if (handler) {
const result = await handler(baseUrl, apiKey);
if (result) return result;
}
// 对于自定义供应商,尝试所有已知的接口模式
if (provider === 'custom' || provider === 'custom-gemini' || provider === 'custom-claude') {
for (const fn of Object.values(handlers)) {
const result = await fn(baseUrl, apiKey);
if (result) return result;
}
}
return null;
}
// --- DeepSeek ---
async function tryDeepSeek(baseUrl, apiKey) {
try {
const root = (baseUrl || 'https://api.deepseek.com/v1').replace(/\/v1\/?$/, '');
const res = await fetchWithTimeout(`${root}/user/balance`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
}, 8000);
if (!res.ok) return null;
const data = await res.json();
if (!data.balance_infos && !data.is_available) return null;
const info = data.balance_infos?.[0] || {};
return {
supported: true,
source: 'deepseek',
balance: parseFloat(info.total_balance || 0),
currency: info.currency || 'CNY',
detail: {
total: parseFloat(info.total_balance || 0),
granted: parseFloat(info.granted_balance || 0),
topped_up: parseFloat(info.topped_up_balance || 0),
},
};
} catch {
return null;
}
}
// --- SiliconFlow ---
async function trySiliconFlow(baseUrl, apiKey) {
try {
const root = (baseUrl || 'https://api.siliconflow.cn/v1').replace(/\/v1\/?$/, '');
const res = await fetchWithTimeout(`${root}/v1/user/info`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
}, 8000);
if (!res.ok) return null;
const data = await res.json();
const d = data.data || data;
if (d.balance == null && d.totalBalance == null) return null;
return {
supported: true,
source: 'siliconflow',
balance: parseFloat(d.balance || 0),
currency: 'CNY',
detail: {
balance: parseFloat(d.balance || 0),
charge_balance: parseFloat(d.chargeBalance || 0),
total_balance: parseFloat(d.totalBalance || 0),
},
};
} catch {
return null;
}
}
// --- OpenRouter ---
async function tryOpenRouter(baseUrl, apiKey) {
try {
const res = await fetchWithTimeout('https://openrouter.ai/api/v1/credits', {
headers: { 'Authorization': `Bearer ${apiKey}` },
}, 8000);
if (!res.ok) return null;
const data = await res.json();
const d = data.data || data;
if (d.total_credits == null && d.balance == null) return null;
const balance = d.balance ?? (d.total_credits - (d.total_usage || 0));
return {
supported: true,
source: 'openrouter',
balance: round(balance),
currency: 'USD',
detail: {
total_credits: d.total_credits,
total_usage: d.total_usage,
remaining: round(balance),
},
};
} catch {
return null;
}
}
// --- Moonshot ---
async function tryMoonshot(baseUrl, apiKey) {
try {
const root = (baseUrl || 'https://api.moonshot.cn/v1').replace(/\/v1\/?$/, '');
const res = await fetchWithTimeout(`${root}/v1/users/me/balance`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
}, 8000);
if (!res.ok) return null;
const data = await res.json();
const d = data.data || data;
if (d.available_balance == null && d.balance == null && d.cash_balance == null) return null;
const balance = d.available_balance ?? d.balance ?? d.cash_balance ?? 0;
return {
supported: true,
source: 'moonshot',
balance: parseFloat(balance),
currency: 'CNY',
detail: {
available: parseFloat(d.available_balance || d.balance || 0),
voucher: parseFloat(d.voucher_balance || 0),
cash: parseFloat(d.cash_balance || 0),
},
};
} catch {
return null;
}
}
// ==================== 工具函数 ====================
function round(n) {
return Math.round(n * 100) / 100;
}
async function fetchWithTimeout(url, options, timeoutMs = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
return res;
} finally {
clearTimeout(timer);
}
}
+3 -3
View File
@@ -11,7 +11,7 @@ export async function POST(request) {
const apiKey = isCustomEmbed ? (apiConfig.embedApiKey || apiConfig?.apiKey) : apiConfig?.apiKey;
// 自动识别默认填写或遗留的智谱URL并矫正为对应官方URL
let defaultBaseUrl = provider === 'gemini-native' ? 'https://generativelanguage.googleapis.com/v1beta' : 'https://open.bigmodel.cn/api/paas/v4';
let defaultBaseUrl = ['gemini-native', 'custom-gemini'].includes(provider) ? 'https://generativelanguage.googleapis.com/v1beta' : 'https://open.bigmodel.cn/api/paas/v4';
let rawBaseUrl;
if (isCustomEmbed) {
@@ -21,7 +21,7 @@ export async function POST(request) {
rawBaseUrl = apiConfig?.baseUrl || defaultBaseUrl;
}
if (!rawBaseUrl || (provider === 'gemini-native' && rawBaseUrl.includes('open.bigmodel.cn'))) {
if (!rawBaseUrl || (['gemini-native', 'custom-gemini'].includes(provider) && rawBaseUrl.includes('open.bigmodel.cn'))) {
rawBaseUrl = defaultBaseUrl;
}
@@ -47,7 +47,7 @@ export async function POST(request) {
let embeddings = [];
if (provider === 'gemini-native') {
if (['gemini-native', 'custom-gemini'].includes(provider)) {
const geminiModel = embedModelName || 'text-embedding-004';
const url = `${baseUrl}/models/${geminiModel}:embedContent?key=${apiKey}`;
console.log('Fetching Gemini Embeddings:', url);
+130
View File
@@ -0,0 +1,130 @@
import { NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
// 数据持久化 API — 将所有用户数据存储到本地文件系统
// 每个用户有独立的目录(通过 userId cookie 隔离)
const DATA_ROOT = process.env.DATA_DIR || path.join(process.cwd(), 'data');
// 从请求中提取或创建用户ID
function getUserId(request) {
// 优先从 cookie 读取
const cookies = request.headers.get('cookie') || '';
const match = cookies.match(/author-uid=([a-zA-Z0-9_-]+)/);
if (match) return match[1];
return null;
}
// 确保目录存在
async function ensureDir(dirPath) {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (e) {
if (e.code !== 'EEXIST') throw e;
}
}
// key → 文件路径映射(防止路径穿越)
function resolveFilePath(userId, key) {
// 仅允许字母、数字、连字符、下划线、斜杠、点
const safeKey = key.replace(/[^a-zA-Z0-9\-_./]/g, '');
if (!safeKey || safeKey.includes('..')) {
throw new Error('Invalid storage key');
}
const userDir = path.join(DATA_ROOT, userId);
const filePath = path.join(userDir, safeKey + '.json');
// 安全检查:确保路径在用户目录内
const resolvedPath = path.resolve(filePath);
const resolvedUserDir = path.resolve(userDir);
if (!resolvedPath.startsWith(resolvedUserDir)) {
throw new Error('Path traversal detected');
}
return filePath;
}
// GET /api/storage?key=xxx — 读取数据
export async function GET(request) {
try {
const userId = getUserId(request);
if (!userId) {
return NextResponse.json({ error: 'No user ID' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'Missing key parameter' }, { status: 400 });
}
const filePath = resolveFilePath(userId, key);
try {
const content = await fs.readFile(filePath, 'utf-8');
return NextResponse.json({ data: JSON.parse(content) });
} catch (e) {
if (e.code === 'ENOENT') {
return NextResponse.json({ data: null });
}
throw e;
}
} catch (error) {
console.error('Storage GET error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
// POST /api/storage — 写入数据 { key, value }
export async function POST(request) {
try {
const userId = getUserId(request);
if (!userId) {
return NextResponse.json({ error: 'No user ID' }, { status: 401 });
}
const { key, value } = await request.json();
if (!key) {
return NextResponse.json({ error: 'Missing key' }, { status: 400 });
}
const filePath = resolveFilePath(userId, key);
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8');
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Storage POST error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
// DELETE /api/storage?key=xxx — 删除数据
export async function DELETE(request) {
try {
const userId = getUserId(request);
if (!userId) {
return NextResponse.json({ error: 'No user ID' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'Missing key parameter' }, { status: 400 });
}
const filePath = resolveFilePath(userId, key);
try {
await fs.unlink(filePath);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Storage DELETE error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+4 -4
View File
@@ -294,9 +294,9 @@ export default function AiSidebar({ onInsertText }) {
try {
const { apiConfig } = getProjectSettings();
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini'
const apiEndpoint = ['gemini-native', 'custom-gemini'].includes(apiConfig?.provider) ? '/api/ai/gemini'
: apiConfig?.provider === 'openai-responses' ? '/api/ai/responses'
: apiConfig?.provider === 'claude' ? '/api/ai/claude'
: ['claude', 'custom-claude'].includes(apiConfig?.provider) ? '/api/ai/claude'
: '/api/ai';
const context = await buildContext(activeChapterId, text, contextSelection.size > 0 ? contextSelection : null);
@@ -361,9 +361,9 @@ export default function AiSidebar({ onInsertText }) {
try {
const { apiConfig } = getProjectSettings();
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini'
const apiEndpoint = ['gemini-native', 'custom-gemini'].includes(apiConfig?.provider) ? '/api/ai/gemini'
: apiConfig?.provider === 'openai-responses' ? '/api/ai/responses'
: apiConfig?.provider === 'claude' ? '/api/ai/claude'
: ['claude', 'custom-claude'].includes(apiConfig?.provider) ? '/api/ai/claude'
: '/api/ai';
const context = await buildContext(activeChapterId, userMsg.content, contextSelection.size > 0 ? contextSelection : null);
+2 -2
View File
@@ -100,9 +100,9 @@ export default function SettingsConflictModal({ conflicts, noConflicts, onConfir
try {
const { apiConfig } = getProjectSettings();
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini'
const apiEndpoint = ['gemini-native', 'custom-gemini'].includes(apiConfig?.provider) ? '/api/ai/gemini'
: apiConfig?.provider === 'openai-responses' ? '/api/ai/responses'
: apiConfig?.provider === 'claude' ? '/api/ai/claude'
: ['claude', 'custom-claude'].includes(apiConfig?.provider) ? '/api/ai/claude'
: '/api/ai';
const existingFields = JSON.stringify(conflict.existing.content || {}, null, 2);
+73 -2
View File
@@ -810,6 +810,8 @@ const PROVIDERS = [
{ key: 'volcengine', label: '火山引擎 (豆包)', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', models: [] },
{ key: 'moonshot', label: 'Moonshot (Kimi)', baseUrl: 'https://api.moonshot.cn/v1', models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'] },
{ key: 'custom', label: '自定义 (OpenAI兼容)', baseUrl: '', models: [] },
{ key: 'custom-gemini', label: '自定义 (Gemini格式)', baseUrl: '', models: [] },
{ key: 'custom-claude', label: '自定义 (Claude格式)', baseUrl: '', models: [] },
];
function PreferencesForm() {
@@ -880,6 +882,7 @@ function ApiConfigForm({ data, onChange }) {
const [fetchedModels, setFetchedModels] = useState(null);
const [fetchedEmbedModels, setFetchedEmbedModels] = useState(null);
const [rebuildStatus, setRebuildStatus] = useState(null); // null | 'loading' | {done, total, failed}
const [balanceInfo, setBalanceInfo] = useState(null); // null | 'loading' | { supported, balance, currency, ... } | { error }
const [savedProfiles, setSavedProfiles] = useState([]);
const [profileName, setProfileName] = useState('');
const [showSaveInput, setShowSaveInput] = useState(false);
@@ -895,6 +898,7 @@ function ApiConfigForm({ data, onChange }) {
const persistProfiles = (profiles) => {
setSavedProfiles(profiles);
localStorage.setItem('author-api-profiles', JSON.stringify(profiles));
import('../lib/persistence').then(m => m.persistSet('author-api-profiles', profiles).catch(() => { }));
};
const handleSaveProfile = () => {
@@ -919,6 +923,7 @@ function ApiConfigForm({ data, onChange }) {
setTestStatus(null);
setFetchedModels(null);
setFetchedEmbedModels(null);
setBalanceInfo(null);
};
const handleTestConnection = async () => {
@@ -966,7 +971,27 @@ function ApiConfigForm({ data, onChange }) {
};
const currentProvider = PROVIDERS.find(p => p.key === data.provider) || PROVIDERS[7];
const isCustom = data.provider === 'custom';
const isCustom = ['custom', 'custom-gemini', 'custom-claude'].includes(data.provider);
// 余额查询
const handleQueryBalance = async () => {
setBalanceInfo('loading');
try {
const res = await fetch('/api/balance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: data.provider, apiKey: data.apiKey, baseUrl: data.baseUrl }),
});
const result = await res.json();
if (res.ok) {
setBalanceInfo(result);
} else {
setBalanceInfo({ error: result.error || '查询失败' });
}
} catch (e) {
setBalanceInfo({ error: e.message || '网络错误' });
}
};
return (
<div>
@@ -1020,6 +1045,52 @@ function ApiConfigForm({ data, onChange }) {
<FieldInput label="API Key" value={data.apiKey} onChange={v => update('apiKey', v)} placeholder={t('apiConfig.apiKeyPlaceholder')} secret />
{data.apiKey && <div style={{ fontSize: 11, color: 'var(--success)', marginTop: -10, marginBottom: 10 }}>{t('apiConfig.apiKeyConfigured')}</div>}
{/* 余额查询卡片 */}
{data.apiKey && (
<div style={{
marginBottom: 14, padding: '10px 14px', borderRadius: 'var(--radius-md)',
background: 'var(--bg-secondary)', border: '1px solid var(--border-light)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)' }}>💰 {t('apiConfig.balance') || 'API 余额'}</span>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: 11, padding: '3px 10px' }}
onClick={handleQueryBalance}
disabled={balanceInfo === 'loading'}
>
{balanceInfo === 'loading' ? (t('apiConfig.balanceQuerying') || '查询中...') : (t('apiConfig.balanceQuery') || '查询余额')}
</button>
</div>
{balanceInfo && balanceInfo !== 'loading' && (
<div style={{ marginTop: 8 }}>
{balanceInfo.error ? (
<div style={{ fontSize: 12, color: 'var(--error)' }}>❌ {balanceInfo.error}</div>
) : balanceInfo.supported === false ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>⚠️ {balanceInfo.message || t('apiConfig.balanceNotSupported') || '当前供应商不支持余额查询'}</div>
) : (
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--accent)', lineHeight: 1.2 }}>
{balanceInfo.currency === 'CNY' ? '¥' : '$'}{balanceInfo.balance?.toFixed(2)}
<span style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>{balanceInfo.currency}</span>
</div>
{balanceInfo.detail && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 14px', marginTop: 6, fontSize: 11, color: 'var(--text-muted)' }}>
{balanceInfo.detail.granted != null && balanceInfo.detail.granted > 0 && <span>赠送: {balanceInfo.detail.granted.toFixed(2)}</span>}
{balanceInfo.detail.topped_up != null && balanceInfo.detail.topped_up > 0 && <span>充值: {balanceInfo.detail.topped_up.toFixed(2)}</span>}
{balanceInfo.detail.used != null && <span>已用: {balanceInfo.detail.used.toFixed(2)}</span>}
{balanceInfo.detail.total_limit != null && <span>额度: {balanceInfo.detail.total_limit.toFixed(2)}</span>}
{balanceInfo.detail.remaining != null && balanceInfo.detail.total_limit != null && <span>剩余: {balanceInfo.detail.remaining.toFixed(2)}</span>}
</div>
)}
{balanceInfo.source && <div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>via {balanceInfo.source}</div>}
</div>
)}
</div>
)}
</div>
)}
<FieldInput label={isCustom ? t('apiConfig.apiAddress') : t('apiConfig.apiAddressAuto')} value={data.baseUrl} onChange={v => update('baseUrl', v)} placeholder={t('apiConfig.apiAddressPlaceholder')} />
{/* 模型选择 */}
@@ -1033,7 +1104,7 @@ function ApiConfigForm({ data, onChange }) {
</button>
)}
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, ...(Array.isArray(fetchedModels) && fetchedModels.length > 20 ? { maxHeight: 200, overflowY: 'auto', padding: 4, border: '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)' } : {}) }}>
{(Array.isArray(fetchedModels) ? fetchedModels.map(m => m.id) : currentProvider.models).map(m => (
<button key={m} style={{ padding: '5px 12px', border: data.model === m ? '2px solid var(--accent)' : '1px solid var(--border-light)', borderRadius: 'var(--radius-sm)', background: data.model === m ? 'var(--accent-light)' : 'var(--bg-primary)', cursor: 'pointer', fontSize: 12, color: data.model === m ? 'var(--accent)' : 'var(--text-primary)', fontFamily: 'monospace' }} onClick={() => update('model', m)}>{m}</button>
))}
+1
View File
@@ -42,6 +42,7 @@ export default function Sidebar({ onOpenHelp, onToggle, editorRef }) {
setTheme(next);
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('author-theme', next);
import('../lib/persistence').then(m => m.persistSet('author-theme', next).catch(() => { }));
}, [theme, setTheme]);
// 中文数字 ↔ 阿拉伯数字 互转
+5 -13
View File
@@ -2,7 +2,7 @@
// ==================== 多会话管理模块 ====================
import { get, set } from 'idb-keyval';
import { persistGet, persistSet } from './persistence';
const STORAGE_KEY = 'author-chat-sessions';
@@ -11,32 +11,24 @@ function generateId(prefix = 'session') {
}
/**
* 从 IndexedDB 读取全部会话数据 (Async)
* 从持久化存储读取全部会话数据 (Async)
*/
export async function loadSessionStore() {
if (typeof window === 'undefined') return { activeSessionId: null, sessions: [] };
try {
let store = await get(STORAGE_KEY);
if (!store) {
// Fallback: migrate from localStorage
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
store = JSON.parse(raw);
await set(STORAGE_KEY, store);
}
}
const store = await persistGet(STORAGE_KEY);
if (store) return store;
} catch { }
return { activeSessionId: null, sessions: [] };
}
/**
* 保存全部会话数据到 IndexedDB (Async)
* 保存全部会话数据到持久化存储 (Async)
*/
export async function saveSessionStore(store) {
if (typeof window === 'undefined') return;
try {
await set(STORAGE_KEY, store);
await persistSet(STORAGE_KEY, store);
} catch { }
}
+2
View File
@@ -28,6 +28,8 @@ const PROVIDER_CN_RATIO = {
'gemini-native': 2.0,
// 默认 / 自定义
custom: 2.0,
'custom-gemini': 2.0,
'custom-claude': 1.8,
};
// 获取当前 provider 的中文 token 比例
+203
View File
@@ -0,0 +1,203 @@
'use client';
// ==================== 持久化适配器 ====================
// 统一的存储接口:优先使用服务端文件系统,fallback 到浏览器 IndexedDB/localStorage
// 多用户隔离:首次访问自动生成 userId 并存入 cookie
import { get, set, del } from 'idb-keyval';
// ==================== 用户ID管理 ====================
function getUserId() {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/author-uid=([a-zA-Z0-9_-]+)/);
return match ? match[1] : null;
}
function ensureUserId() {
let uid = getUserId();
if (!uid) {
uid = 'u-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
// 设置 365 天有效的 cookie(HttpOnly = false,前端可读)
document.cookie = `author-uid=${uid}; path=/; max-age=${365 * 24 * 3600}; SameSite=Lax`;
}
return uid;
}
// ==================== 服务端存储 ====================
let _serverAvailable = null; // null = 未检测, true/false = 检测结果
async function checkServerAvailable() {
if (_serverAvailable !== null) return _serverAvailable;
try {
const res = await fetch('/api/storage?key=__ping', {
method: 'GET',
credentials: 'include',
});
_serverAvailable = res.ok || res.status === 401; // 401 也说明服务端在运行
return _serverAvailable;
} catch {
_serverAvailable = false;
return false;
}
}
async function serverGet(key) {
const res = await fetch(`/api/storage?key=${encodeURIComponent(key)}`, {
method: 'GET',
credentials: 'include',
});
if (!res.ok) throw new Error(`Server GET failed: ${res.status}`);
const { data } = await res.json();
return data;
}
async function serverSet(key, value) {
const res = await fetch('/api/storage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ key, value }),
});
if (!res.ok) throw new Error(`Server POST failed: ${res.status}`);
}
async function serverDel(key) {
const res = await fetch(`/api/storage?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error(`Server DELETE failed: ${res.status}`);
}
// ==================== 统一存储接口 ====================
/**
* 读取数据(服务端优先,fallback 到 IndexedDB)
* @param {string} key - 存储键名
* @returns {Promise<any>} 存储的值,不存在时返回 undefined
*/
export async function persistGet(key) {
if (typeof window === 'undefined') return undefined;
ensureUserId();
try {
if (await checkServerAvailable()) {
const data = await serverGet(key);
if (data !== null && data !== undefined) return data;
// 服务端没有,尝试从浏览器迁移
const localData = await browserGet(key);
if (localData !== null && localData !== undefined) {
// 自动迁移到服务端
await serverSet(key, localData).catch(() => { });
return localData;
}
return undefined;
}
} catch {
// 服务端失败,降级到浏览器
}
return await browserGet(key);
}
/**
* 写入数据(同时写入服务端和浏览器)
* @param {string} key - 存储键名
* @param {any} value - 要存储的值
*/
export async function persistSet(key, value) {
if (typeof window === 'undefined') return;
ensureUserId();
// 先写浏览器(立即可用)
await browserSet(key, value);
// 异步写服务端(不阻塞 UI)
if (await checkServerAvailable()) {
serverSet(key, value).catch(err => {
console.warn('[persist] Server write failed, data saved in browser only:', err.message);
});
}
}
/**
* 删除数据
* @param {string} key - 存储键名
*/
export async function persistDel(key) {
if (typeof window === 'undefined') return;
await browserDel(key);
if (await checkServerAvailable()) {
serverDel(key).catch(() => { });
}
}
// ==================== 浏览器存储桥接 ====================
// 大数据用 IndexedDB,小数据用 localStorage
const LOCALSTORAGE_KEYS = new Set([
'author-project-settings',
'author-active-work',
'author-token-stats',
'author-theme',
'author-lang',
'author-visual',
'author-onboarding-done',
'author-context-selection',
'author-api-profiles',
'author-delete-never-remind',
'author-delete-skip-today',
]);
async function browserGet(key) {
if (LOCALSTORAGE_KEYS.has(key)) {
const raw = localStorage.getItem(key);
if (raw === null) return undefined;
try { return JSON.parse(raw); } catch { return raw; }
}
const val = await get(key);
return val === undefined ? undefined : val;
}
async function browserSet(key, value) {
if (LOCALSTORAGE_KEYS.has(key)) {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
return;
}
await set(key, value);
}
async function browserDel(key) {
if (LOCALSTORAGE_KEYS.has(key)) {
localStorage.removeItem(key);
return;
}
await del(key);
}
// ==================== 便捷方法 ====================
/**
* 同步读取 localStorage(仅用于需要同步值的场景,如初始化 zustand store)
* 不走服务端。
*/
export function persistGetSync(key) {
if (typeof window === 'undefined') return undefined;
const raw = localStorage.getItem(key);
if (raw === null) return undefined;
try { return JSON.parse(raw); } catch { return raw; }
}
/**
* 初始化:确保 userId 存在,并触发服务端可用性检测
* 应在应用启动时调用一次
*/
export async function initPersistence() {
if (typeof window === 'undefined') return;
ensureUserId();
await checkServerAvailable();
}
+15 -19
View File
@@ -2,7 +2,7 @@
// 这些信息会在每次AI调用时作为上下文传入,让AI像Cursor一样了解整个项目
// 基于「叙事引擎」架构 — 支持网络小说、传统文学、剧本/脚本三种创作模式
import { get, set } from 'idb-keyval';
import { persistGet, persistSet } from './persistence';
import { getEmbedding } from './embeddings';
const SETTINGS_KEY = 'author-project-settings';
@@ -204,10 +204,12 @@ export function getProjectSettings() {
}
}
// 保存项目设定
// 保存项目设定(同步写 localStorage + 异步写服务端)
export function saveProjectSettings(settings) {
if (typeof window === 'undefined') return;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
// 异步写入服务端(不阻塞 UI)
persistSet(SETTINGS_KEY, settings).catch(() => { });
}
// 添加角色
@@ -345,6 +347,7 @@ export function setActiveWorkId(workId) {
if (typeof window === 'undefined') return;
if (workId) {
localStorage.setItem(ACTIVE_WORK_KEY, workId);
persistSet(ACTIVE_WORK_KEY, workId).catch(() => { });
} else {
localStorage.removeItem(ACTIVE_WORK_KEY);
}
@@ -367,24 +370,17 @@ function getDefaultNodes() {
export async function getSettingsNodes() {
if (typeof window === 'undefined') return getDefaultNodes();
try {
let nodes = await get(NODES_KEY);
let nodes = await persistGet(NODES_KEY);
if (!nodes) {
// 首次使用:尝试从 localStorage fallback 或迁移旧数据
const legacyData = localStorage.getItem(NODES_KEY);
if (legacyData) {
nodes = JSON.parse(legacyData);
await set(NODES_KEY, nodes); // 写入 IndexedDB
} else {
const migrated = await migrateOldSettings();
if (migrated) {
nodes = await migrateToWorkStructure(migrated);
return nodes;
}
const defaults = getDefaultNodes();
await saveSettingsNodes(defaults);
if (!getActiveWorkId()) setActiveWorkId('work-default');
return defaults;
const migrated = await migrateOldSettings();
if (migrated) {
nodes = await migrateToWorkStructure(migrated);
return nodes;
}
const defaults = getDefaultNodes();
await saveSettingsNodes(defaults);
if (!getActiveWorkId()) setActiveWorkId('work-default');
return defaults;
}
nodes = await migrateToWorkStructure(nodes);
@@ -400,7 +396,7 @@ export async function getSettingsNodes() {
// 保存设定集节点 (Async)
export async function saveSettingsNodes(nodes) {
if (typeof window === 'undefined') return;
await set(NODES_KEY, nodes);
await persistSet(NODES_KEY, nodes);
}
/**
+4 -4
View File
@@ -1,4 +1,4 @@
import { get, set } from 'idb-keyval';
import { persistGet, persistSet } from './persistence';
import { getChapters, saveChapters } from './storage';
import { getSettingsNodes, saveSettingsNodes, getActiveWorkId } from './settings';
@@ -10,7 +10,7 @@ const SNAPSHOTS_KEY = 'author-snapshots';
*/
export async function getSnapshots() {
try {
const snapshots = await get(SNAPSHOTS_KEY);
const snapshots = await persistGet(SNAPSHOTS_KEY);
return Array.isArray(snapshots) ? snapshots : [];
} catch (e) {
console.error('Failed to get snapshots:', e);
@@ -57,7 +57,7 @@ export async function createSnapshot(label, type = 'auto') {
finalSnapshots = existing.filter(s => !toRemove.includes(s.id));
}
await set(SNAPSHOTS_KEY, finalSnapshots);
await persistSet(SNAPSHOTS_KEY, finalSnapshots);
return snapshot;
} catch (e) {
console.error('Failed to create snapshot:', e);
@@ -96,6 +96,6 @@ export async function restoreSnapshot(snapshotId) {
export async function deleteSnapshot(snapshotId) {
const snapshots = await getSnapshots();
const remaining = snapshots.filter(s => s.id !== snapshotId);
await set(SNAPSHOTS_KEY, remaining);
await persistSet(SNAPSHOTS_KEY, remaining);
return remaining;
}
+13 -29
View File
@@ -1,6 +1,8 @@
// 本地存储工具 - 使用 IndexedDB 管理核心数据 (章节、摘要)
// 本地存储工具 - 使用持久化适配器管理核心数据 (章节、摘要)
// 优先服务端文件系统,fallback 到浏览器 IndexedDB
// 章节按作品(workId)隔离存储
import { get, set, del } from 'idb-keyval';
import { persistGet, persistSet, persistDel } from './persistence';
const LEGACY_STORAGE_KEY = 'author-chapters';
@@ -20,25 +22,14 @@ export function generateId() {
export async function migrateGlobalChapters(workId) {
if (typeof window === 'undefined' || !workId) return;
try {
const perWorkData = await get(getStorageKey(workId));
const perWorkData = await persistGet(getStorageKey(workId));
if (perWorkData) return; // 该作品已有数据,不迁移
const globalData = await get(LEGACY_STORAGE_KEY);
if (!globalData || !Array.isArray(globalData) || globalData.length === 0) {
// 也检查 localStorage fallback
const legacyLocal = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacyLocal) {
const parsed = JSON.parse(legacyLocal);
if (Array.isArray(parsed) && parsed.length > 0) {
await set(getStorageKey(workId), parsed);
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
}
return;
const globalData = await persistGet(LEGACY_STORAGE_KEY);
if (globalData && Array.isArray(globalData) && globalData.length > 0) {
await persistSet(getStorageKey(workId), globalData);
await persistDel(LEGACY_STORAGE_KEY);
}
// 剪切到新 key
await set(getStorageKey(workId), globalData);
await del(LEGACY_STORAGE_KEY);
} catch (e) {
console.warn('[迁移] 章节迁移失败:', e);
}
@@ -49,7 +40,7 @@ export async function getChapters(workId) {
if (typeof window === 'undefined') return [];
const key = getStorageKey(workId);
try {
let chapters = await get(key);
let chapters = await persistGet(key);
if (!chapters) {
chapters = [];
}
@@ -62,7 +53,7 @@ export async function getChapters(workId) {
// 保存所有章节 (Async)
export async function saveChapters(chapters, workId) {
if (typeof window === 'undefined') return;
await set(getStorageKey(workId), chapters);
await persistSet(getStorageKey(workId), chapters);
}
// 创建新章节 (Async)
@@ -166,14 +157,7 @@ const SUMMARY_PREFIX = 'author-chapter-summary-';
export async function getChapterSummary(id) {
if (typeof window === 'undefined') return null;
try {
let summary = await get(SUMMARY_PREFIX + id);
if (!summary) {
// Fallback
summary = localStorage.getItem(SUMMARY_PREFIX + id);
if (summary) {
await set(SUMMARY_PREFIX + id, summary);
}
}
const summary = await persistGet(SUMMARY_PREFIX + id);
return summary || null;
} catch {
return null;
@@ -183,5 +167,5 @@ export async function getChapterSummary(id) {
// 保存章节摘要 (Async)
export async function saveChapterSummary(id, summary) {
if (typeof window === 'undefined') return;
await set(SUMMARY_PREFIX + id, summary);
await persistSet(SUMMARY_PREFIX + id, summary);
}
+4
View File
@@ -1,5 +1,7 @@
// Token 使用统计模块 — 记录每次 AI 请求的 token 用量、速度,并计算消耗预估
import { persistSet } from './persistence';
const STORAGE_KEY = 'author-token-stats';
/**
@@ -43,6 +45,7 @@ export function addTokenRecord(record) {
model: record.model || 'unknown',
});
localStorage.setItem(STORAGE_KEY, JSON.stringify({ records }));
persistSet(STORAGE_KEY, { records }).catch(() => { });
}
/**
@@ -51,6 +54,7 @@ export function addTokenRecord(record) {
export function clearTokenStats() {
if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ records: [] }));
persistSet(STORAGE_KEY, { records: [] }).catch(() => { });
}
/**
+5 -1
View File
@@ -469,7 +469,11 @@
"maxOutputTokensDesc": "Maximum tokens per model response",
"reasoningEffort": "Reasoning Effort",
"reasoningAuto": "Auto (no override)",
"reasoningEffortDesc": "Controls reasoning depth, only affects models that support reasoning"
"reasoningEffortDesc": "Controls reasoning depth, only affects models that support reasoning",
"balance": "API Balance",
"balanceQuery": "Check Balance",
"balanceQuerying": "Checking…",
"balanceNotSupported": "Balance check not supported for this provider"
},
"preferences": {
"intro": "You can change your language and visual theme preferences here at any time. All changes take effect immediately and are saved locally.",
+5 -1
View File
@@ -469,7 +469,11 @@
"maxOutputTokensDesc": "模型单次回复的最大 token 数",
"reasoningEffort": "思考层级",
"reasoningAuto": "自动(不干预)",
"reasoningEffortDesc": "控制模型的推理深度,仅对支持推理的模型生效"
"reasoningEffortDesc": "控制模型的推理深度,仅对支持推理的模型生效",
"balance": "API 余额",
"balanceQuery": "查询余额",
"balanceQuerying": "查询中…",
"balanceNotSupported": "当前供应商不支持余额查询"
},
"preferences": {
"intro": "在这里可以随时更改应用程序的语言和视觉主题偏好。所有的更改都会实时生效并保存在本地。",
+2 -2
View File
@@ -223,9 +223,9 @@ export default function Home() {
const userPrompt = compileUserPrompt(mode, text, instruction);
const { apiConfig } = getProjectSettings();
const apiEndpoint = apiConfig?.provider === 'gemini-native' ? '/api/ai/gemini'
const apiEndpoint = ['gemini-native', 'custom-gemini'].includes(apiConfig?.provider) ? '/api/ai/gemini'
: apiConfig?.provider === 'openai-responses' ? '/api/ai/responses'
: apiConfig?.provider === 'claude' ? '/api/ai/claude'
: ['claude', 'custom-claude'].includes(apiConfig?.provider) ? '/api/ai/claude'
: '/api/ai';
const res = await fetch(apiEndpoint, {
+12 -3
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persistSet } from '../lib/persistence';
export const useAppStore = create((set, get) => ({
// --- Chapter State ---
@@ -41,13 +42,19 @@ export const useAppStore = create((set, get) => ({
// --- Localization & Theming ---
language: typeof window !== 'undefined' ? localStorage.getItem('author-lang') || null : null,
setLanguage: (lang) => set(() => {
if (typeof window !== 'undefined') localStorage.setItem('author-lang', lang);
if (typeof window !== 'undefined') {
localStorage.setItem('author-lang', lang);
persistSet('author-lang', lang).catch(() => { });
}
return { language: lang };
}),
visualTheme: typeof window !== 'undefined' ? localStorage.getItem('author-visual') || null : null,
setVisualTheme: (vTheme) => set(() => {
if (typeof window !== 'undefined') localStorage.setItem('author-visual', vTheme);
if (typeof window !== 'undefined') {
localStorage.setItem('author-visual', vTheme);
persistSet('author-visual', vTheme).catch(() => { });
}
return { visualTheme: vTheme };
}),
@@ -71,7 +78,9 @@ export const useAppStore = create((set, get) => ({
setContextSelection: (selection) => set((state) => {
const newSelection = typeof selection === 'function' ? selection(state.contextSelection) : selection;
if (typeof window !== 'undefined') {
localStorage.setItem('author-context-selection', JSON.stringify(Array.from(newSelection)));
const arr = Array.from(newSelection);
localStorage.setItem('author-context-selection', JSON.stringify(arr));
persistSet('author-context-selection', arr).catch(() => { });
}
return { contextSelection: newSelection };
}),
+45
View File
@@ -0,0 +1,45 @@
# ============================================
# Author App — Docker Compose(含反向代理)
# ============================================
#
# 带域名访问(自动 HTTPS):
# 1. 将 author.example.com 替换为你的域名
# 2. 确保域名已解析到服务器 IP
# 3. docker compose -f docker-compose.caddy.yml up -d
# 4. 浏览器访问 https://你的域名
#
# ============================================
services:
author-app:
image: yuanshijiloong/author:latest
container_name: author-studio
expose:
- "3000"
env_file:
- path: .env
required: false
- path: .env.local
required: false
environment:
- NODE_ENV=production
- HOSTNAME=0.0.0.0
restart: unless-stopped
caddy:
image: caddy:2-alpine
container_name: author-caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- author-app
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
+2
View File
@@ -26,4 +26,6 @@ services:
environment:
- NODE_ENV=production
- HOSTNAME=0.0.0.0
volumes:
- ./data:/app/data
restart: unless-stopped
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "author-app",
"version": "1.1.11",
"version": "1.1.12",
"private": true,
"main": "electron/main.js",
"scripts": {