v1.1.12: add API balance query, persistence improvements, Docker enhancements
This commit is contained in:
1 parent
584e206e48
commit
7f7dc609b7
30 files changed
+1300
-139
No files matched your search
@@ -13,3 +13,4 @@ afterPack.js
|
||||
.idea
|
||||
.antigravityignore
|
||||
.agents
|
||||
data
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
# 将 author.example.com 替换为你的域名
|
||||
# Replace author.example.com with your domain
|
||||
|
||||
author.example.com {
|
||||
reverse_proxy author-app:3000
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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 [];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// 中文数字 ↔ 阿拉伯数字 互转
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 比例
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -469,7 +469,11 @@
|
||||
"maxOutputTokensDesc": "模型单次回复的最大 token 数",
|
||||
"reasoningEffort": "思考层级",
|
||||
"reasoningAuto": "自动(不干预)",
|
||||
"reasoningEffortDesc": "控制模型的推理深度,仅对支持推理的模型生效"
|
||||
"reasoningEffortDesc": "控制模型的推理深度,仅对支持推理的模型生效",
|
||||
"balance": "API 余额",
|
||||
"balanceQuery": "查询余额",
|
||||
"balanceQuerying": "查询中…",
|
||||
"balanceNotSupported": "当前供应商不支持余额查询"
|
||||
},
|
||||
"preferences": {
|
||||
"intro": "在这里可以随时更改应用程序的语言和视觉主题偏好。所有的更改都会实时生效并保存在本地。",
|
||||
|
||||
+2
-2
@@ -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, {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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:
|
||||
@@ -26,4 +26,6 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOSTNAME=0.0.0.0
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "author-app",
|
||||
"version": "1.1.11",
|
||||
"version": "1.1.12",
|
||||
"private": true,
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
|
||||
Reference in new issue
Block a user