diff --git a/.dockerignore b/.dockerignore index a8e3b97..9f5f43b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,4 @@ afterPack.js .idea .antigravityignore .agents +data diff --git a/.gitignore b/.gitignore index 33cd8ff..1cf9b58 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..a659614 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,6 @@ +# 将 author.example.com 替换为你的域名 +# Replace author.example.com with your domain + +author.example.com { + reverse_proxy author-app:3000 +} diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..99f8b9d --- /dev/null +++ b/DOCKER.md @@ -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/) 后即可使用。 diff --git a/DOCKER_EN.md b/DOCKER_EN.md new file mode 100644 index 0000000..b45bc41 --- /dev/null +++ b/DOCKER_EN.md @@ -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. diff --git a/Dockerfile b/Dockerfile index b178599..38e1eac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/app/api/ai/gemini/models/route.js b/app/api/ai/gemini/models/route.js index 2d7019a..31dd0ab 100644 --- a/app/api/ai/gemini/models/route.js +++ b/app/api/ai/gemini/models/route.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 []; +} diff --git a/app/api/ai/models/route.js b/app/api/ai/models/route.js index 62b8072..bd1b40a 100644 --- a/app/api/ai/models/route.js +++ b/app/api/ai/models/route.js @@ -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 []; +} diff --git a/app/api/ai/test/route.js b/app/api/ai/test/route.js index 066546a..ed6ac73 100644 --- a/app/api/ai/test/route.js +++ b/app/api/ai/test/route.js @@ -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); } diff --git a/app/api/balance/route.js b/app/api/balance/route.js new file mode 100644 index 0000000..bfec580 --- /dev/null +++ b/app/api/balance/route.js @@ -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); + } +} diff --git a/app/api/embed/route.js b/app/api/embed/route.js index 55185d9..f29383e 100644 --- a/app/api/embed/route.js +++ b/app/api/embed/route.js @@ -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); diff --git a/app/api/storage/route.js b/app/api/storage/route.js new file mode 100644 index 0000000..989518d --- /dev/null +++ b/app/api/storage/route.js @@ -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 }); + } +} diff --git a/app/components/AiSidebar.js b/app/components/AiSidebar.js index 52fe8f2..4721aa0 100644 --- a/app/components/AiSidebar.js +++ b/app/components/AiSidebar.js @@ -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); diff --git a/app/components/SettingsConflictModal.js b/app/components/SettingsConflictModal.js index adc7bcb..21018ff 100644 --- a/app/components/SettingsConflictModal.js +++ b/app/components/SettingsConflictModal.js @@ -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); diff --git a/app/components/SettingsPanel.js b/app/components/SettingsPanel.js index ea60d3e..848b193 100644 --- a/app/components/SettingsPanel.js +++ b/app/components/SettingsPanel.js @@ -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 (