refactor(potluck): 简化 API 大锅饭系统并增强安全性和 UI
- 移除凭证管理和资源包系统,简化为基于每日限额的 Key 管理 - 新增登录安全防护(频率限制、账户锁定、IP 追踪) - 重构日志系统使用 AsyncLocalStorage 替代全局状态 - 全面升级 UI 界面(主题切换、使用分布统计、响应式设计) - 优化安装脚本(PowerShell 支持、手动安装指引) BREAKING CHANGE: API Potluck 插件不再支持凭证资源包功能,所有 Key 仅基于每日限额进行配额管理。user-data-manager 模块已禁用,相关 API 端点已移除。
This commit is contained in:
parent
8456f64615
commit
fa19bae517
28 changed files with 1878 additions and 4173 deletions
|
|
@ -138,6 +138,13 @@ docker compose up -d
|
|||
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
||||
* **Windows**: `install-and-run.bat` をダブルクリックして実行
|
||||
|
||||
> **💡 スクリプトの実行に失敗した場合は、手動で依存関係をインストールして起動できます:**
|
||||
> ```bash
|
||||
> npm install
|
||||
> npm start
|
||||
> ```
|
||||
|
||||
|
||||
#### 2. コンソールへのアクセス
|
||||
サーバー起動後、ブラウザで以下にアクセスしてください:
|
||||
👉 [**http://localhost:3000**](http://localhost:3000)
|
||||
|
|
|
|||
|
|
@ -137,6 +137,13 @@ docker compose up -d
|
|||
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
||||
* **Windows**: 双击运行 `install-and-run.bat`
|
||||
|
||||
> **💡 如果脚本运行失败,可以尝试手动安装依赖并启动:**
|
||||
> ```bash
|
||||
> npm install
|
||||
> npm start
|
||||
> ```
|
||||
|
||||
|
||||
#### 2. 访问控制台
|
||||
服务器启动后,打开浏览器访问:
|
||||
👉 [**http://localhost:3000**](http://localhost:3000)
|
||||
|
|
|
|||
|
|
@ -138,6 +138,13 @@ To build from source instead of using the pre-built image, edit `docker-compose.
|
|||
* **Linux/macOS**: `chmod +x install-and-run.sh && ./install-and-run.sh`
|
||||
* **Windows**: Double-click `install-and-run.bat`
|
||||
|
||||
> **💡 If the script fails, you can try manually installing dependencies and starting:**
|
||||
> ```bash
|
||||
> npm install
|
||||
> npm start
|
||||
> ```
|
||||
|
||||
|
||||
#### 2. Access the console
|
||||
After the server starts, open your browser and visit:
|
||||
👉 [**http://localhost:3000**](http://localhost:3000)
|
||||
|
|
|
|||
|
|
@ -1,101 +1,16 @@
|
|||
@echo off
|
||||
rem Ensure the script uses Windows CRLF line endings and UTF-8 encoding
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: 处理参数
|
||||
set FORCE_PULL=0
|
||||
|
||||
for %%a in (%*) do (
|
||||
if "%%a"=="--pull" set FORCE_PULL=1
|
||||
)
|
||||
|
||||
echo ========================================
|
||||
echo AI Client 2 API 快速安装启动脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: 检查Git并尝试pull
|
||||
if !FORCE_PULL! equ 1 (
|
||||
echo [更新] 正在从远程仓库拉取最新代码...
|
||||
git --version >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
git pull
|
||||
if %errorlevel% neq 0 (
|
||||
echo [警告] Git pull 失败,请检查网络或手动处理冲突。
|
||||
) else (
|
||||
echo [成功] 代码已更新。
|
||||
)
|
||||
) else (
|
||||
echo [警告] 未检测到 Git,跳过代码拉取。
|
||||
)
|
||||
)
|
||||
|
||||
:: 检查Node.js是否已安装
|
||||
echo [检查] 正在检查Node.js是否已安装...
|
||||
node --version >nul 2>&1
|
||||
:: Check for powershell
|
||||
where powershell >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 未检测到Node.js,请先安装Node.js
|
||||
echo 下载地址:https://nodejs.org/
|
||||
echo 提示:推荐安装LTS版本
|
||||
echo [ERROR] PowerShell not found. Please run 'node src/core/master.js' manually.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 获取Node.js版本
|
||||
for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i
|
||||
echo [成功] Node.js已安装,版本: !NODE_VERSION!
|
||||
:: Launch PowerShell script
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-and-run.ps1" %*
|
||||
|
||||
:: 检查package.json是否存在
|
||||
if not exist "package.json" (
|
||||
echo [错误] 未找到package.json文件
|
||||
echo 请确保在项目根目录下运行此脚本
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [成功] 找到package.json文件
|
||||
|
||||
:: 检查 pnpm 是否安装
|
||||
where pnpm >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
set PKG_MANAGER=pnpm
|
||||
) else (
|
||||
set PKG_MANAGER=npm
|
||||
)
|
||||
|
||||
echo [安装] 正在使用 !PKG_MANAGER! 安装/更新依赖...
|
||||
echo 这可能需要几分钟时间,请耐心等待...
|
||||
echo 正在执行: !PKG_MANAGER! install...
|
||||
|
||||
call !PKG_MANAGER! install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 依赖安装失败
|
||||
echo 请检查网络连接或手动运行 '!PKG_MANAGER! install'
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [成功] 依赖安装/更新完成
|
||||
|
||||
:: 检查src目录和master.js是否存在
|
||||
if not exist "src\core\master.js" (
|
||||
echo [错误] 未找到src\core\master.js文件
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [成功] 项目文件检查完成
|
||||
|
||||
:: 启动应用程序
|
||||
echo.
|
||||
echo ========================================
|
||||
echo 启动AIClient2API服务器...
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 服务器将在 http://localhost:3000 启动
|
||||
echo 访问 http://localhost:3000 查看管理界面
|
||||
echo 按 Ctrl+C 停止服务器
|
||||
echo.
|
||||
|
||||
:: 启动服务器
|
||||
node src\core\master.js
|
||||
endlocal
|
||||
|
|
|
|||
71
install-and-run.ps1
Normal file
71
install-and-run.ps1
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# install-and-run.ps1
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " AI Client 2 API 快速安装启动脚本" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 处理参数
|
||||
$forcePull = $args -contains "--pull"
|
||||
|
||||
# 检查 Git 并拉取
|
||||
if ($forcePull) {
|
||||
Write-Host "[更新] 正在从远程仓库拉取最新代码..."
|
||||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||
git pull
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Git pull 失败,请检查网络或手动处理冲突。"
|
||||
} else {
|
||||
Write-Host "[成功] 代码已更新。" -ForegroundColor Green
|
||||
}
|
||||
} else {
|
||||
Write-Warning "未检测到 Git,跳过代码拉取。"
|
||||
}
|
||||
}
|
||||
|
||||
# 检查 Node.js
|
||||
Write-Host "[检查] 正在检查Node.js是否已安装..."
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "[错误] 未检测到Node.js,请先安装Node.js (https://nodejs.org/)" -ForegroundColor Red
|
||||
Pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
$nodeVersion = node --version
|
||||
Write-Host "[成功] Node.js已安装,版本: $nodeVersion" -ForegroundColor Green
|
||||
|
||||
# 检查 package.json
|
||||
if (-not (Test-Path "package.json")) {
|
||||
Write-Host "[错误] 未找到package.json文件,请确保在项目根目录下运行此脚本" -ForegroundColor Red
|
||||
Pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 确定包管理器
|
||||
$pkgManager = if (Get-Command pnpm -ErrorAction SilentlyContinue) { "pnpm" } else { "npm" }
|
||||
Write-Host "[安装] 正在使用 $pkgManager 安装/更新依赖..." -ForegroundColor Cyan
|
||||
|
||||
& $pkgManager install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "[错误] 依赖安装失败,请检查网络连接。" -ForegroundColor Red
|
||||
Pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查主文件
|
||||
if (-not (Test-Path "src\core\master.js")) {
|
||||
Write-Host "[错误] 未找到 src\core\master.js 文件" -ForegroundColor Red
|
||||
Pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " 启动 AIClient2API 服务器..." -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host "服务器将在 http://localhost:3000 启动"
|
||||
Write-Host "按 Ctrl+C 停止服务器"
|
||||
Write-Host ""
|
||||
|
||||
node src\core\master.js
|
||||
|
|
@ -54,50 +54,58 @@ function normalizeConfiguredProviders(config) {
|
|||
* @returns {Object} The initialized configuration object.
|
||||
*/
|
||||
export async function initializeConfig(args = process.argv.slice(2), configFilePath = 'configs/config.json') {
|
||||
let currentConfig = {};
|
||||
const defaultConfig = {
|
||||
REQUIRED_API_KEY: "123456",
|
||||
SERVER_PORT: 3000,
|
||||
HOST: '0.0.0.0',
|
||||
MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI,
|
||||
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
|
||||
SYSTEM_PROMPT_MODE: 'append',
|
||||
PROXY_URL: null, // HTTP/HTTPS/SOCKS5 代理地址,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
|
||||
PROXY_ENABLED_PROVIDERS: [], // 启用代理的提供商列表,如 ['gemini-cli-oauth', 'claude-kiro-oauth']
|
||||
PROMPT_LOG_BASE_NAME: "prompt_log",
|
||||
PROMPT_LOG_MODE: "none",
|
||||
REQUEST_MAX_RETRIES: 3,
|
||||
REQUEST_BASE_DELAY: 1000,
|
||||
CREDENTIAL_SWITCH_MAX_RETRIES: 5, // 坏凭证切换最大重试次数(用于认证错误后切换凭证)
|
||||
CRON_NEAR_MINUTES: 15,
|
||||
CRON_REFRESH_TOKEN: false,
|
||||
LOGIN_EXPIRY: 3600, // 登录过期时间(秒),默认1小时
|
||||
LOGIN_MAX_ATTEMPTS: 5, // 最大失败重试次数
|
||||
LOGIN_LOCKOUT_DURATION: 1800, // 锁定持续时间(秒),默认30分钟
|
||||
LOGIN_MIN_INTERVAL: 5000, // 两次尝试之间的最小间隔(毫秒),默认1秒
|
||||
PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径
|
||||
MAX_ERROR_COUNT: 10, // 提供商最大错误次数
|
||||
providerFallbackChain: {}, // 跨类型 Fallback 链配置
|
||||
LOG_ENABLED: true,
|
||||
LOG_OUTPUT_MODE: "all",
|
||||
LOG_LEVEL: "info",
|
||||
LOG_DIR: "logs",
|
||||
LOG_INCLUDE_REQUEST_ID: true,
|
||||
LOG_INCLUDE_TIMESTAMP: true,
|
||||
LOG_MAX_FILE_SIZE: 10485760,
|
||||
LOG_MAX_FILES: 10,
|
||||
TLS_SIDECAR_ENABLED: false, // 启用 Go uTLS sidecar(需要编译 tls-sidecar 二进制)
|
||||
TLS_SIDECAR_PORT: 9090, // sidecar 监听端口
|
||||
TLS_SIDECAR_BINARY_PATH: null // 自定义二进制路径(默认自动搜索)
|
||||
};
|
||||
|
||||
let currentConfig = { ...defaultConfig };
|
||||
|
||||
try {
|
||||
const configData = fs.readFileSync(configFilePath, 'utf8');
|
||||
currentConfig = JSON.parse(configData);
|
||||
const loadedConfig = JSON.parse(configData);
|
||||
Object.assign(currentConfig, loadedConfig);
|
||||
logger.info('[Config] Loaded configuration from configs/config.json');
|
||||
} catch (error) {
|
||||
logger.error('[Config Error] Failed to load configs/config.json:', error.message);
|
||||
// Fallback to default values if config.json is not found or invalid
|
||||
currentConfig = {
|
||||
REQUIRED_API_KEY: "123456",
|
||||
SERVER_PORT: 3000,
|
||||
HOST: '0.0.0.0',
|
||||
MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI,
|
||||
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
|
||||
SYSTEM_PROMPT_MODE: 'append',
|
||||
PROXY_URL: null, // HTTP/HTTPS/SOCKS5 代理地址,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
|
||||
PROXY_ENABLED_PROVIDERS: [], // 启用代理的提供商列表,如 ['gemini-cli-oauth', 'claude-kiro-oauth']
|
||||
PROMPT_LOG_BASE_NAME: "prompt_log",
|
||||
PROMPT_LOG_MODE: "none",
|
||||
REQUEST_MAX_RETRIES: 3,
|
||||
REQUEST_BASE_DELAY: 1000,
|
||||
CREDENTIAL_SWITCH_MAX_RETRIES: 5, // 坏凭证切换最大重试次数(用于认证错误后切换凭证)
|
||||
CRON_NEAR_MINUTES: 15,
|
||||
CRON_REFRESH_TOKEN: false,
|
||||
LOGIN_EXPIRY: 3600, // 登录过期时间(秒),默认1小时
|
||||
PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径
|
||||
MAX_ERROR_COUNT: 10, // 提供商最大错误次数
|
||||
providerFallbackChain: {}, // 跨类型 Fallback 链配置
|
||||
LOG_ENABLED: true,
|
||||
LOG_OUTPUT_MODE: "all",
|
||||
LOG_LEVEL: "info",
|
||||
LOG_DIR: "logs",
|
||||
LOG_INCLUDE_REQUEST_ID: true,
|
||||
LOG_INCLUDE_TIMESTAMP: true,
|
||||
LOG_MAX_FILE_SIZE: 10485760,
|
||||
LOG_MAX_FILES: 10,
|
||||
TLS_SIDECAR_ENABLED: false, // 启用 Go uTLS sidecar(需要编译 tls-sidecar 二进制)
|
||||
TLS_SIDECAR_PORT: 9090, // sidecar 监听端口
|
||||
TLS_SIDECAR_BINARY_PATH: null // 自定义二进制路径(默认自动搜索)
|
||||
};
|
||||
logger.info('[Config] Using default configuration.');
|
||||
if (error.code !== 'ENOENT') {
|
||||
logger.error('[Config Error] Failed to load configs/config.json:', error.message);
|
||||
} else {
|
||||
logger.info('[Config] configs/config.json not found, using default configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// CLI argument definitions: { flag, configKey, type, validValues? }
|
||||
// type: 'string' | 'int' | 'bool' | 'enum'
|
||||
const cliArgDefs = [
|
||||
|
|
@ -113,6 +121,9 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP
|
|||
{ flag: '--cron-refresh-token', configKey: 'CRON_REFRESH_TOKEN', type: 'bool' },
|
||||
{ flag: '--provider-pools-file', configKey: 'PROVIDER_POOLS_FILE_PATH', type: 'string' },
|
||||
{ flag: '--max-error-count', configKey: 'MAX_ERROR_COUNT', type: 'int' },
|
||||
{ flag: '--login-max-attempts', configKey: 'LOGIN_MAX_ATTEMPTS', type: 'int' },
|
||||
{ flag: '--login-lockout-duration', configKey: 'LOGIN_LOCKOUT_DURATION', type: 'int' },
|
||||
{ flag: '--login-min-interval', configKey: 'LOGIN_MIN_INTERVAL', type: 'int' },
|
||||
];
|
||||
|
||||
// Parse command-line arguments using definitions
|
||||
|
|
|
|||
|
|
@ -50,218 +50,222 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
// Generate unique request ID and set it in logger context
|
||||
const clientIp = getClientIp(req);
|
||||
const requestId = `${clientIp}:${generateRequestId()}`;
|
||||
logger.setRequestContext(requestId);
|
||||
|
||||
// Deep copy the config for each request to allow dynamic modification
|
||||
const currentConfig = deepmerge({}, config);
|
||||
|
||||
// 计算当前请求的基础 URL
|
||||
const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
||||
const host = req.headers.host;
|
||||
currentConfig.requestBaseUrl = `${protocol}://${host}`;
|
||||
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
let path = requestUrl.pathname;
|
||||
const method = req.method;
|
||||
return logger.runWithContext(requestId, async () => {
|
||||
// Deep copy the config for each request to allow dynamic modification
|
||||
const currentConfig = deepmerge({}, config);
|
||||
|
||||
// 计算当前请求的基础 URL
|
||||
const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
||||
const host = req.headers.host;
|
||||
currentConfig.requestBaseUrl = `${protocol}://${host}`;
|
||||
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
let path = requestUrl.pathname;
|
||||
const method = req.method;
|
||||
|
||||
// Set CORS headers for all requests
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider, X-Requested-With, Accept, Origin');
|
||||
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache for preflight
|
||||
|
||||
// Handle CORS preflight requests
|
||||
if (method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve static files for UI (除了登录页面需要认证)
|
||||
// 检查是否是插件静态文件
|
||||
const pluginManager = getPluginManager();
|
||||
const isPluginStatic = pluginManager.isPluginStaticPath(path);
|
||||
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) {
|
||||
const served = await serveStaticFiles(path, res);
|
||||
if (served) return;
|
||||
}
|
||||
|
||||
// 执行插件路由
|
||||
const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res);
|
||||
if (pluginRouteHandled) return;
|
||||
|
||||
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
|
||||
if (uiHandled) return;
|
||||
|
||||
// logger.info(`\n${new Date().toLocaleString()}`);
|
||||
logger.info(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
|
||||
|
||||
// Health check endpoint
|
||||
if (method === 'GET' && path === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: currentConfig.MODEL_PROVIDER
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Grok assets proxy endpoint
|
||||
if (method === 'GET' && path === '/api/grok/assets') {
|
||||
await handleGrokAssetsProxy(req, res, currentConfig, providerPoolManager);
|
||||
return true;
|
||||
}
|
||||
|
||||
// providers health endpoint
|
||||
// url params: provider[string], customName[string], unhealthRatioThreshold[float]
|
||||
// 支持provider, customName过滤记录
|
||||
// 支持unhealthRatioThreshold控制不健康比例的阈值, 当unhealthyRatio超过阈值返回summaryHealthy: false
|
||||
if (method === 'GET' && path === '/provider_health') {
|
||||
try {
|
||||
const provider = requestUrl.searchParams.get('provider');
|
||||
const customName = requestUrl.searchParams.get('customName');
|
||||
let unhealthRatioThreshold = requestUrl.searchParams.get('unhealthRatioThreshold');
|
||||
unhealthRatioThreshold = unhealthRatioThreshold === null ? 0.0001 : parseFloat(unhealthRatioThreshold);
|
||||
let provideStatus = await getProviderStatus(currentConfig, { provider, customName });
|
||||
let summaryHealth = true;
|
||||
if (!isNaN(unhealthRatioThreshold)) {
|
||||
summaryHealth = provideStatus.unhealthyRatio <= unhealthRatioThreshold;
|
||||
// Set CORS headers for all requests
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider, X-Requested-With, Accept, Origin');
|
||||
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache for preflight
|
||||
|
||||
// Handle CORS preflight requests
|
||||
if (method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve static files for UI (除了登录页面需要认证)
|
||||
// 检查是否是插件静态文件
|
||||
const pluginManager = getPluginManager();
|
||||
const isPluginStatic = pluginManager.isPluginStaticPath(path);
|
||||
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) {
|
||||
const served = await serveStaticFiles(path, res);
|
||||
if (served) return;
|
||||
}
|
||||
|
||||
// 执行插件路由
|
||||
const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res);
|
||||
if (pluginRouteHandled) return;
|
||||
|
||||
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
|
||||
if (uiHandled) return;
|
||||
|
||||
// logger.info(`\n${new Date().toLocaleString()}`);
|
||||
logger.info(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
|
||||
|
||||
// Health check endpoint
|
||||
if (method === 'GET' && path === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: currentConfig.MODEL_PROVIDER
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Grok assets proxy endpoint
|
||||
if (method === 'GET' && path === '/api/grok/assets') {
|
||||
await handleGrokAssetsProxy(req, res, currentConfig, providerPoolManager);
|
||||
return true;
|
||||
}
|
||||
|
||||
// providers health endpoint
|
||||
// url params: provider[string], customName[string], unhealthRatioThreshold[float]
|
||||
// 支持provider, customName过滤记录
|
||||
// 支持unhealthRatioThreshold控制不健康比例的阈值, 当unhealthyRatio超过阈值返回summaryHealthy: false
|
||||
if (method === 'GET' && path === '/provider_health') {
|
||||
try {
|
||||
const provider = requestUrl.searchParams.get('provider');
|
||||
const customName = requestUrl.searchParams.get('customName');
|
||||
let unhealthRatioThreshold = requestUrl.searchParams.get('unhealthRatioThreshold');
|
||||
unhealthRatioThreshold = unhealthRatioThreshold === null ? 0.0001 : parseFloat(unhealthRatioThreshold);
|
||||
let provideStatus = await getProviderStatus(currentConfig, { provider, customName });
|
||||
let summaryHealth = true;
|
||||
if (!isNaN(unhealthRatioThreshold)) {
|
||||
summaryHealth = provideStatus.unhealthyRatio <= unhealthRatioThreshold;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
items: provideStatus.providerPoolsSlim,
|
||||
count: provideStatus.count,
|
||||
unhealthyCount: provideStatus.unhealthyCount,
|
||||
unhealthyRatio: provideStatus.unhealthyRatio,
|
||||
unhealthySummeryMessage: provideStatus.unhealthySummeryMessage,
|
||||
summaryHealth
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.info(`[Server] req provider_health error: ${error.message}`);
|
||||
handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
items: provideStatus.providerPoolsSlim,
|
||||
count: provideStatus.count,
|
||||
unhealthyCount: provideStatus.unhealthyCount,
|
||||
unhealthyRatio: provideStatus.unhealthyRatio,
|
||||
unhealthySummeryMessage: provideStatus.unhealthySummeryMessage,
|
||||
summaryHealth
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.info(`[Server] req provider_health error: ${error.message}`);
|
||||
handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle API requests
|
||||
// Allow overriding MODEL_PROVIDER via request header
|
||||
const modelProviderHeader = req.headers['model-provider'];
|
||||
if (modelProviderHeader) {
|
||||
const registeredProviders = getRegisteredProviders();
|
||||
if (registeredProviders.includes(modelProviderHeader)) {
|
||||
currentConfig.MODEL_PROVIDER = modelProviderHeader;
|
||||
logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
|
||||
} else {
|
||||
logger.warn(`[Config] Provider ${modelProviderHeader} in header is not available.`);
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `Provider ${modelProviderHeader} is not available.` } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the first path segment matches a MODEL_PROVIDER and switch if it does
|
||||
const pathSegments = path.split('/').filter(segment => segment.length > 0);
|
||||
|
||||
if (pathSegments.length > 0) {
|
||||
const firstSegment = pathSegments[0];
|
||||
const registeredProviders = getRegisteredProviders();
|
||||
const isValidProvider = registeredProviders.includes(firstSegment);
|
||||
const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO;
|
||||
// Handle API requests
|
||||
// Allow overriding MODEL_PROVIDER via request header
|
||||
const modelProviderHeader = req.headers['model-provider'];
|
||||
if (modelProviderHeader) {
|
||||
const registeredProviders = getRegisteredProviders();
|
||||
if (registeredProviders.includes(modelProviderHeader)) {
|
||||
currentConfig.MODEL_PROVIDER = modelProviderHeader;
|
||||
logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
|
||||
} else {
|
||||
logger.warn(`[Config] Provider ${modelProviderHeader} in header is not available.`);
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `Provider ${modelProviderHeader} is not available.` } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the first path segment matches a MODEL_PROVIDER and switch if it does
|
||||
const pathSegments = path.split('/').filter(segment => segment.length > 0);
|
||||
|
||||
if (pathSegments.length > 0) {
|
||||
const firstSegment = pathSegments[0];
|
||||
const registeredProviders = getRegisteredProviders();
|
||||
const isValidProvider = registeredProviders.includes(firstSegment);
|
||||
const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO;
|
||||
|
||||
if (firstSegment && (isValidProvider || isAutoMode)) {
|
||||
currentConfig.MODEL_PROVIDER = firstSegment;
|
||||
logger.info(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`);
|
||||
pathSegments.shift();
|
||||
path = '/' + pathSegments.join('/');
|
||||
requestUrl.pathname = path;
|
||||
} else if (firstSegment && Object.values(MODEL_PROVIDER).includes(firstSegment)) {
|
||||
// 如果在 MODEL_PROVIDER 中但没注册适配器,拦截并报错
|
||||
logger.warn(`[Config] Provider ${firstSegment} is recognized but no adapter is registered.`);
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `Provider ${firstSegment} is not available.` } }));
|
||||
return;
|
||||
} else if (firstSegment && !isValidProvider) {
|
||||
logger.info(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`);
|
||||
}
|
||||
}
|
||||
if (firstSegment && (isValidProvider || isAutoMode)) {
|
||||
currentConfig.MODEL_PROVIDER = firstSegment;
|
||||
logger.info(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`);
|
||||
pathSegments.shift();
|
||||
path = '/' + pathSegments.join('/');
|
||||
requestUrl.pathname = path;
|
||||
} else if (firstSegment && Object.values(MODEL_PROVIDER).includes(firstSegment)) {
|
||||
// 如果在 MODEL_PROVIDER 中但没注册适配器,拦截并报错
|
||||
logger.warn(`[Config] Provider ${firstSegment} is recognized but no adapter is registered.`);
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: `Provider ${firstSegment} is not available.` } }));
|
||||
return;
|
||||
} else if (firstSegment && !isValidProvider) {
|
||||
logger.info(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 执行认证流程(只有 type='auth' 的插件参与)
|
||||
const authResult = await pluginManager.executeAuth(req, res, requestUrl, currentConfig);
|
||||
if (authResult.handled) {
|
||||
// 认证插件已处理请求(如发送了错误响应)
|
||||
return;
|
||||
}
|
||||
if (!authResult.authorized) {
|
||||
// 没有认证插件授权,返回 401
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行普通中间件(type!='auth' 的插件)
|
||||
const middlewareResult = await pluginManager.executeMiddleware(req, res, requestUrl, currentConfig);
|
||||
if (middlewareResult.handled) {
|
||||
// 中间件已处理请求
|
||||
return;
|
||||
}
|
||||
// 1. 执行认证流程(只有 type='auth' 的插件参与)
|
||||
const authResult = await pluginManager.executeAuth(req, res, requestUrl, currentConfig);
|
||||
if (authResult.handled) {
|
||||
// 认证插件已处理请求(如发送了错误响应)
|
||||
return;
|
||||
}
|
||||
if (!authResult.authorized) {
|
||||
// 没有认证插件授权,返回 401
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行普通中间件(type!='auth' 的插件)
|
||||
const middlewareResult = await pluginManager.executeMiddleware(req, res, requestUrl, currentConfig);
|
||||
if (middlewareResult.handled) {
|
||||
// 中间件已处理请求
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle count_tokens requests (Anthropic API compatible)
|
||||
if (path.includes('/count_tokens') && method === 'POST') {
|
||||
try {
|
||||
const body = await parseRequestBody(req);
|
||||
logger.info(`[Server] Handling count_tokens request for model: ${body.model}`);
|
||||
// Handle count_tokens requests (Anthropic API compatible)
|
||||
if (path.includes('/count_tokens') && method === 'POST') {
|
||||
try {
|
||||
const body = await parseRequestBody(req);
|
||||
logger.info(`[Server] Handling count_tokens request for model: ${body.model}`);
|
||||
|
||||
// Use common utility method directly
|
||||
try {
|
||||
const result = countTokensAnthropic(body);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (tokenError) {
|
||||
logger.warn(`[Server] Common countTokens failed, falling back: ${tokenError.message}`);
|
||||
// Last resort: return 0
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ input_tokens: 0 }));
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[Server] count_tokens error: ${error.message}`);
|
||||
handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取或选择 API Service 实例
|
||||
let apiService;
|
||||
// try {
|
||||
// apiService = await getApiService(currentConfig);
|
||||
// } catch (error) {
|
||||
// handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
// const poolManager = getProviderPoolManager();
|
||||
// if (poolManager) {
|
||||
// poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, {
|
||||
// uuid: currentConfig.uuid
|
||||
// });
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Use common utility method directly
|
||||
try {
|
||||
const result = countTokensAnthropic(body);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (tokenError) {
|
||||
logger.warn(`[Server] Common countTokens failed, falling back: ${tokenError.message}`);
|
||||
// Last resort: return 0
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ input_tokens: 0 }));
|
||||
// Handle API requests
|
||||
const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME);
|
||||
if (apiHandled) return;
|
||||
|
||||
// Fallback for unmatched routes
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
|
||||
} catch (error) {
|
||||
handleError(res, error, currentConfig.MODEL_PROVIDER);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[Server] count_tokens error: ${error.message}`);
|
||||
handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
return;
|
||||
} finally {
|
||||
// Clear request context after request is complete
|
||||
logger.clearRequestContext(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取或选择 API Service 实例
|
||||
let apiService;
|
||||
// try {
|
||||
// apiService = await getApiService(currentConfig);
|
||||
// } catch (error) {
|
||||
// handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
// const poolManager = getProviderPoolManager();
|
||||
// if (poolManager) {
|
||||
// poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, {
|
||||
// uuid: currentConfig.uuid
|
||||
// });
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
|
||||
try {
|
||||
// Handle API requests
|
||||
const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME);
|
||||
if (apiHandled) return;
|
||||
|
||||
// Fallback for unmatched routes
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
|
||||
} catch (error) {
|
||||
handleError(res, error, currentConfig.MODEL_PROVIDER);
|
||||
} finally {
|
||||
// Clear request context after request is complete
|
||||
logger.clearRequestContext(requestId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,17 +30,16 @@ import {
|
|||
sendPotluckError
|
||||
} from './middleware.js';
|
||||
|
||||
import { consumeBonus, getConfig } from './user-data-manager.js';
|
||||
import logger from '../../utils/logger.js';
|
||||
|
||||
import { handlePotluckApiRoutes, handlePotluckUserApiRoutes, startHealthCheckScheduler, stopHealthCheckScheduler } from './api-routes.js';
|
||||
import { handlePotluckApiRoutes, handlePotluckUserApiRoutes } from './api-routes.js';
|
||||
|
||||
/**
|
||||
* 插件定义
|
||||
*/
|
||||
const apiPotluckPlugin = {
|
||||
name: 'api-potluck',
|
||||
version: '1.0.1',
|
||||
version: '1.0.2',
|
||||
description: 'API 大锅饭 - Key 管理和用量统计插件<br>管理端:<a href="potluck.html" target="_blank">potluck.html</a><br>用户端:<a href="potluck-user.html" target="_blank">potluck-user.html</a>',
|
||||
|
||||
// 插件类型:认证插件
|
||||
|
|
@ -55,10 +54,6 @@ const apiPotluckPlugin = {
|
|||
*/
|
||||
async init(config) {
|
||||
logger.info('[API Potluck Plugin] Initializing...');
|
||||
// 注入配置获取函数
|
||||
setConfigGetter(getConfig);
|
||||
// 启动定时健康检查
|
||||
startHealthCheckScheduler();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -66,8 +61,6 @@ const apiPotluckPlugin = {
|
|||
*/
|
||||
async destroy() {
|
||||
logger.info('[API Potluck Plugin] Destroying...');
|
||||
// 停止定时健康检查
|
||||
stopHealthCheckScheduler();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -155,21 +148,24 @@ const apiPotluckPlugin = {
|
|||
hooks: {
|
||||
/**
|
||||
* 内容生成后钩子 - 记录用量
|
||||
* @param {Object} config - 服务器配置
|
||||
* @param {Object} hookContext - 钩子上下文,包含请求和模型信息
|
||||
*/
|
||||
async onContentGenerated(config) {
|
||||
if (config.potluckApiKey) {
|
||||
async onContentGenerated(hookContext) {
|
||||
if (hookContext.potluckApiKey) {
|
||||
try {
|
||||
// 传入资源包消耗回调
|
||||
await incrementUsage(config.potluckApiKey, async (apiKey) => {
|
||||
await consumeBonus(apiKey);
|
||||
});
|
||||
// 传入提供商和模型信息
|
||||
await incrementUsage(
|
||||
hookContext.potluckApiKey,
|
||||
hookContext.toProvider,
|
||||
hookContext.model
|
||||
);
|
||||
} catch (e) {
|
||||
// 静默失败,不影响主流程
|
||||
logger.error('[API Potluck Plugin] Failed to record usage:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// 导出内部函数供外部使用(可选)
|
||||
|
|
@ -186,7 +182,6 @@ const apiPotluckPlugin = {
|
|||
incrementUsage,
|
||||
getStats,
|
||||
KEY_PREFIX,
|
||||
getConfig,
|
||||
extractPotluckKey,
|
||||
isPotluckRequest
|
||||
}
|
||||
|
|
@ -208,7 +203,6 @@ export {
|
|||
incrementUsage,
|
||||
getStats,
|
||||
KEY_PREFIX,
|
||||
getConfig,
|
||||
extractPotluckKey,
|
||||
isPotluckRequest
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// 配置常量
|
||||
// 配置文件路径
|
||||
const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json');
|
||||
const KEY_PREFIX = 'maki_';
|
||||
|
||||
// 默认配置(会被 user-data-manager 的配置覆盖)
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG = {
|
||||
defaultDailyLimit: 500,
|
||||
persistInterval: 5000
|
||||
|
|
@ -56,18 +56,6 @@ function ensureLoaded() {
|
|||
if (existsSync(KEYS_STORE_FILE)) {
|
||||
const content = readFileSync(KEYS_STORE_FILE, 'utf8');
|
||||
keyStore = JSON.parse(content);
|
||||
// 兼容历史数据:为旧 Key 添加 bonusRemaining 字段
|
||||
let needsMigration = false;
|
||||
for (const keyData of Object.values(keyStore.keys)) {
|
||||
if (keyData.bonusRemaining === undefined) {
|
||||
keyData.bonusRemaining = 0;
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
if (needsMigration) {
|
||||
logger.info('[API Potluck] Migrated legacy keys: added bonusRemaining field');
|
||||
markDirty();
|
||||
}
|
||||
} else {
|
||||
keyStore = { keys: {} };
|
||||
syncWriteToFile();
|
||||
|
|
@ -178,6 +166,7 @@ function checkAndResetDailyCount(keyData) {
|
|||
|
||||
/**
|
||||
* 创建新的 API Key
|
||||
|
||||
* @param {string} name - Key 名称
|
||||
* @param {number} [dailyLimit] - 每日限额,不传则使用配置的默认值
|
||||
*/
|
||||
|
|
@ -199,8 +188,7 @@ export async function createKey(name = '', dailyLimit = null) {
|
|||
totalUsage: 0,
|
||||
lastResetDate: today,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
bonusRemaining: 0 // 剩余资源包总次数(由同步检查更新)
|
||||
enabled: true
|
||||
};
|
||||
|
||||
keyStore.keys[apiKey] = keyData;
|
||||
|
|
@ -333,7 +321,7 @@ export async function regenerateKey(oldKeyId) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 验证 API Key 是否有效且有配额(每日限额 + 资源包)
|
||||
* 验证 API Key 是否有效且有配额
|
||||
*/
|
||||
export async function validateKey(apiKey) {
|
||||
ensureLoaded();
|
||||
|
|
@ -349,13 +337,7 @@ export async function validateKey(apiKey) {
|
|||
|
||||
// 检查每日限额
|
||||
if (keyData.todayUsage < keyData.dailyLimit) {
|
||||
return { valid: true, keyData, useBonus: false };
|
||||
}
|
||||
|
||||
// 每日限额用尽,检查资源包
|
||||
const bonusRemaining = keyData.bonusRemaining || 0;
|
||||
if (bonusRemaining > 0) {
|
||||
return { valid: true, keyData, useBonus: true, bonusRemaining };
|
||||
return { valid: true, keyData };
|
||||
}
|
||||
|
||||
return { valid: false, reason: 'quota_exceeded', keyData };
|
||||
|
|
@ -363,47 +345,55 @@ export async function validateKey(apiKey) {
|
|||
|
||||
/**
|
||||
* 增加 Key 的使用次数(原子操作,直接修改内存)
|
||||
* 优先消耗每日限额,用尽后消耗资源包
|
||||
* @param {string} apiKey - API Key
|
||||
* @param {Function} [onBonusUsed] - 资源包消耗回调,用于更新 data 中的 usedCount
|
||||
* @param {string} provider - 使用的提供商
|
||||
* @param {string} model - 使用的模型
|
||||
*/
|
||||
export async function incrementUsage(apiKey, onBonusUsed = null) {
|
||||
export async function incrementUsage(apiKey, provider = 'unknown', model = 'unknown') {
|
||||
ensureLoaded();
|
||||
const keyData = keyStore.keys[apiKey];
|
||||
if (!keyData) return null;
|
||||
|
||||
checkAndResetDailyCount(keyData);
|
||||
|
||||
let usedBonus = false;
|
||||
|
||||
// 优先消耗每日限额
|
||||
// 消耗每日限额
|
||||
if (keyData.todayUsage < keyData.dailyLimit) {
|
||||
keyData.todayUsage += 1;
|
||||
} else {
|
||||
// 每日限额用尽,消耗资源包
|
||||
const bonusRemaining = keyData.bonusRemaining || 0;
|
||||
|
||||
if (bonusRemaining > 0) {
|
||||
keyData.bonusRemaining = bonusRemaining - 1;
|
||||
usedBonus = true;
|
||||
|
||||
// 触发回调更新 data 中的 usedCount
|
||||
if (onBonusUsed) {
|
||||
await onBonusUsed(apiKey);
|
||||
}
|
||||
} else {
|
||||
// 无可用配额
|
||||
return null;
|
||||
}
|
||||
// 每日限额用尽
|
||||
return null;
|
||||
}
|
||||
|
||||
keyData.totalUsage += 1;
|
||||
keyData.lastUsedAt = new Date().toISOString();
|
||||
|
||||
// 记录个人按天统计 (每个 Key 独立)
|
||||
const today = getTodayDateString();
|
||||
if (!keyData.usageHistory) keyData.usageHistory = {};
|
||||
if (!keyData.usageHistory[today]) {
|
||||
keyData.usageHistory[today] = { providers: {}, models: {} };
|
||||
}
|
||||
|
||||
// 确保 provider 和 model 是字符串
|
||||
const pName = String(provider || 'unknown');
|
||||
const mName = String(model || 'unknown');
|
||||
|
||||
const userHistory = keyData.usageHistory[today];
|
||||
userHistory.providers[pName] = (userHistory.providers[pName] || 0) + 1;
|
||||
userHistory.models[mName] = (userHistory.models[mName] || 0) + 1;
|
||||
|
||||
// 清理该 Key 的过期历史 (保留 7 天)
|
||||
const userDates = Object.keys(keyData.usageHistory).sort();
|
||||
if (userDates.length > 7) {
|
||||
const dropDates = userDates.slice(0, userDates.length - 7);
|
||||
dropDates.forEach(d => delete keyData.usageHistory[d]);
|
||||
}
|
||||
|
||||
markDirty();
|
||||
|
||||
return {
|
||||
...keyData,
|
||||
usedBonus
|
||||
usedBonus: false
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -414,12 +404,36 @@ export async function getStats() {
|
|||
ensureLoaded();
|
||||
const keys = Object.values(keyStore.keys);
|
||||
let enabledKeys = 0, todayTotalUsage = 0, totalUsage = 0;
|
||||
const aggregatedHistory = {};
|
||||
|
||||
for (const key of keys) {
|
||||
checkAndResetDailyCount(key);
|
||||
if (key.enabled) enabledKeys++;
|
||||
todayTotalUsage += key.todayUsage;
|
||||
totalUsage += key.totalUsage;
|
||||
|
||||
// 汇总每个 Key 的历史数据
|
||||
if (key.usageHistory) {
|
||||
Object.entries(key.usageHistory).forEach(([date, history]) => {
|
||||
if (!aggregatedHistory[date]) {
|
||||
aggregatedHistory[date] = { providers: {}, models: {} };
|
||||
}
|
||||
|
||||
// 汇总提供商
|
||||
if (history.providers) {
|
||||
Object.entries(history.providers).forEach(([p, count]) => {
|
||||
aggregatedHistory[date].providers[p] = (aggregatedHistory[date].providers[p] || 0) + count;
|
||||
});
|
||||
}
|
||||
|
||||
// 汇总模型
|
||||
if (history.models) {
|
||||
Object.entries(history.models).forEach(([m, count]) => {
|
||||
aggregatedHistory[date].models[m] = (aggregatedHistory[date].models[m] || 0) + count;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -427,48 +441,11 @@ export async function getStats() {
|
|||
enabledKeys,
|
||||
disabledKeys: keys.length - enabledKeys,
|
||||
todayTotalUsage,
|
||||
totalUsage
|
||||
totalUsage,
|
||||
usageHistory: aggregatedHistory
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 凭证资源包管理 ============
|
||||
|
||||
/**
|
||||
* 更新 Key 的剩余资源包次数(由同步检查调用)
|
||||
* @param {string} keyId - Key ID
|
||||
* @param {number} bonusRemaining - 剩余资源包总次数
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function updateBonusRemaining(keyId, bonusRemaining) {
|
||||
ensureLoaded();
|
||||
const keyData = keyStore.keys[keyId];
|
||||
if (!keyData) return false;
|
||||
|
||||
keyData.bonusRemaining = Math.max(0, bonusRemaining);
|
||||
markDirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Key 的资源包信息
|
||||
* @param {string} keyId - Key ID
|
||||
* @param {Function} getConfigFn - 获取配置的函数(从 user-data-manager 传入)
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function getBonusInfo(keyId, getConfigFn = null) {
|
||||
ensureLoaded();
|
||||
const keyData = keyStore.keys[keyId];
|
||||
if (!keyData) return null;
|
||||
|
||||
// 从 user-data-manager 获取配置
|
||||
const config = getConfigFn ? getConfigFn() : { bonusPerCredential: 300, bonusValidityDays: 30 };
|
||||
|
||||
return {
|
||||
bonusRemaining: keyData.bonusRemaining || 0,
|
||||
bonusPerCredential: config.bonusPerCredential,
|
||||
validityDays: config.bonusValidityDays
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新所有 Key 的每日限额
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { validateKey, incrementUsage, KEY_PREFIX } from './key-manager.js';
|
||||
import logger from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* 从请求中提取 Potluck API Key
|
||||
|
|
|
|||
|
|
@ -1,722 +1,6 @@
|
|||
/**
|
||||
* API 大锅饭 - 用户数据管理模块
|
||||
* 管理用户关联的凭据文件路径和资源包
|
||||
* 使用 Mutex 解决并发问题
|
||||
* 注意:此模块已禁用,所有凭证管理和资源包相关功能已移除
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import logger from '../../utils/logger.js';
|
||||
import { existsSync, readFileSync, writeFileSync, watch } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// 配置文件路径
|
||||
const USER_DATA_FILE = path.join(process.cwd(), 'configs', 'api-potluck-data.json');
|
||||
|
||||
// 默认配置值
|
||||
const DEFAULT_CONFIG = {
|
||||
defaultDailyLimit: 500,
|
||||
bonusPerCredential: 300,
|
||||
bonusValidityDays: 30,
|
||||
persistInterval: 5000
|
||||
};
|
||||
|
||||
// 内存缓存
|
||||
let userDataStore = null;
|
||||
let isDirty = false;
|
||||
let isWriting = false;
|
||||
let persistTimer = null;
|
||||
let fileWatcher = null;
|
||||
let currentPersistInterval = DEFAULT_CONFIG.persistInterval;
|
||||
|
||||
// ============ 简易 Mutex 实现 ============
|
||||
class SimpleMutex {
|
||||
constructor() {
|
||||
this._locked = false;
|
||||
this._waiting = [];
|
||||
}
|
||||
|
||||
async acquire() {
|
||||
return new Promise((resolve) => {
|
||||
if (!this._locked) {
|
||||
this._locked = true;
|
||||
resolve();
|
||||
} else {
|
||||
this._waiting.push(resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
release() {
|
||||
if (this._waiting.length > 0) {
|
||||
const next = this._waiting.shift();
|
||||
next();
|
||||
} else {
|
||||
this._locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runExclusive(fn) {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局锁:用于资源包消耗操作
|
||||
const bonusMutex = new SimpleMutex();
|
||||
|
||||
// ============ 配置管理 ============
|
||||
|
||||
/**
|
||||
* 获取完整配置(支持热更新)
|
||||
*/
|
||||
function getFullConfig() {
|
||||
ensureLoaded();
|
||||
const config = userDataStore.config || {};
|
||||
return {
|
||||
defaultDailyLimit: config.defaultDailyLimit ?? DEFAULT_CONFIG.defaultDailyLimit,
|
||||
bonusPerCredential: config.bonusPerCredential ?? DEFAULT_CONFIG.bonusPerCredential,
|
||||
bonusValidityDays: config.bonusValidityDays ?? DEFAULT_CONFIG.bonusValidityDays,
|
||||
persistInterval: config.persistInterval ?? DEFAULT_CONFIG.persistInterval
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源包配置(兼容旧接口)
|
||||
*/
|
||||
function getBonusConfig() {
|
||||
const config = getFullConfig();
|
||||
return {
|
||||
bonusPerCredential: config.bonusPerCredential,
|
||||
bonusValidityDays: config.bonusValidityDays
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
* @param {Object} newConfig - 新配置
|
||||
* @returns {Object} 更新后的完整配置
|
||||
*/
|
||||
export async function updateConfig(newConfig) {
|
||||
ensureLoaded();
|
||||
|
||||
if (!userDataStore.config) {
|
||||
userDataStore.config = {};
|
||||
}
|
||||
|
||||
// 验证并更新各配置项
|
||||
if (typeof newConfig.defaultDailyLimit === 'number' && newConfig.defaultDailyLimit > 0) {
|
||||
userDataStore.config.defaultDailyLimit = newConfig.defaultDailyLimit;
|
||||
}
|
||||
if (typeof newConfig.bonusPerCredential === 'number' && newConfig.bonusPerCredential >= 0) {
|
||||
userDataStore.config.bonusPerCredential = newConfig.bonusPerCredential;
|
||||
}
|
||||
if (typeof newConfig.bonusValidityDays === 'number' && newConfig.bonusValidityDays > 0) {
|
||||
userDataStore.config.bonusValidityDays = newConfig.bonusValidityDays;
|
||||
}
|
||||
if (typeof newConfig.persistInterval === 'number' && newConfig.persistInterval >= 1000) {
|
||||
userDataStore.config.persistInterval = newConfig.persistInterval;
|
||||
// 更新持久化定时器
|
||||
updatePersistTimer(newConfig.persistInterval);
|
||||
}
|
||||
|
||||
markDirty();
|
||||
await persistIfDirty();
|
||||
|
||||
const updatedConfig = getFullConfig();
|
||||
logger.info(`[API Potluck UserData] Config updated:`, updatedConfig);
|
||||
return updatedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新持久化定时器间隔
|
||||
*/
|
||||
function updatePersistTimer(newInterval) {
|
||||
if (newInterval === currentPersistInterval) return;
|
||||
|
||||
currentPersistInterval = newInterval;
|
||||
if (persistTimer) {
|
||||
clearInterval(persistTimer);
|
||||
persistTimer = setInterval(persistIfDirty, currentPersistInterval);
|
||||
logger.info(`[API Potluck UserData] Persist interval updated to ${currentPersistInterval}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置(对外暴露)
|
||||
*/
|
||||
export function getConfig() {
|
||||
return getFullConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧接口:更新资源包配置
|
||||
*/
|
||||
export async function updateBonusConfig(newConfig) {
|
||||
return updateConfig(newConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:从文件加载数据到内存
|
||||
*/
|
||||
function ensureLoaded() {
|
||||
if (userDataStore !== null) return;
|
||||
try {
|
||||
if (existsSync(USER_DATA_FILE)) {
|
||||
const content = readFileSync(USER_DATA_FILE, 'utf8');
|
||||
userDataStore = JSON.parse(content);
|
||||
// 兼容旧数据:确保 config 和 users 存在
|
||||
if (!userDataStore.config) {
|
||||
userDataStore.config = {};
|
||||
}
|
||||
if (!userDataStore.users) {
|
||||
userDataStore.users = {};
|
||||
}
|
||||
} else {
|
||||
userDataStore = { config: {}, users: {} };
|
||||
syncWriteToFile();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[API Potluck UserData] Failed to load user data:', error.message);
|
||||
userDataStore = { config: {}, users: {} };
|
||||
}
|
||||
|
||||
// 获取配置的持久化间隔
|
||||
const config = userDataStore.config || {};
|
||||
currentPersistInterval = config.persistInterval ?? DEFAULT_CONFIG.persistInterval;
|
||||
|
||||
// 启动定期持久化
|
||||
if (!persistTimer) {
|
||||
persistTimer = setInterval(persistIfDirty, currentPersistInterval);
|
||||
}
|
||||
// 启动文件监听(热更新)
|
||||
startFileWatcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步写入文件(仅初始化时使用)
|
||||
*/
|
||||
function syncWriteToFile() {
|
||||
try {
|
||||
const dir = path.dirname(USER_DATA_FILE);
|
||||
if (!existsSync(dir)) {
|
||||
require('fs').mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(USER_DATA_FILE, JSON.stringify(userDataStore, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
logger.error('[API Potluck UserData] Sync write failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步持久化(带写锁)
|
||||
*/
|
||||
async function persistIfDirty() {
|
||||
if (!isDirty || isWriting || userDataStore === null) return;
|
||||
isWriting = true;
|
||||
try {
|
||||
const dir = path.dirname(USER_DATA_FILE);
|
||||
if (!existsSync(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
const tempFile = USER_DATA_FILE + '.tmp';
|
||||
await fs.writeFile(tempFile, JSON.stringify(userDataStore, null, 2), 'utf8');
|
||||
await fs.rename(tempFile, USER_DATA_FILE);
|
||||
isDirty = false;
|
||||
} catch (error) {
|
||||
logger.error('[API Potluck UserData] Persist failed:', error.message);
|
||||
} finally {
|
||||
isWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记数据已修改
|
||||
*/
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动文件监听(热更新配置)
|
||||
*/
|
||||
let lastReloadTime = 0;
|
||||
function startFileWatcher() {
|
||||
if (fileWatcher) return;
|
||||
|
||||
try {
|
||||
fileWatcher = watch(USER_DATA_FILE, { persistent: false }, (eventType) => {
|
||||
if (eventType !== 'change') return;
|
||||
|
||||
// 防抖:忽略自己写入触发的事件
|
||||
const now = Date.now();
|
||||
if (now - lastReloadTime < 1000 || isWriting) return;
|
||||
lastReloadTime = now;
|
||||
|
||||
// 重新加载配置部分
|
||||
try {
|
||||
const content = readFileSync(USER_DATA_FILE, 'utf8');
|
||||
const newData = JSON.parse(content);
|
||||
|
||||
// 只热更新 config 部分,不覆盖内存中的 users 数据
|
||||
if (newData.config) {
|
||||
const oldConfig = userDataStore.config || {};
|
||||
const newConfig = newData.config;
|
||||
|
||||
// 检查配置是否有变化
|
||||
if (JSON.stringify(oldConfig) !== JSON.stringify(newConfig)) {
|
||||
userDataStore.config = newConfig;
|
||||
logger.info('[API Potluck UserData] Config hot-reloaded:', getBonusConfig());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[API Potluck UserData] Hot-reload failed:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[API Potluck UserData] File watcher started for config hot-reload');
|
||||
} catch (error) {
|
||||
logger.error('[API Potluck UserData] Failed to start file watcher:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止文件监听
|
||||
*/
|
||||
export function stopFileWatcher() {
|
||||
if (fileWatcher) {
|
||||
fileWatcher.close();
|
||||
fileWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户数据
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function getUserData(apiKey) {
|
||||
ensureLoaded();
|
||||
return userDataStore.users[apiKey] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化用户数据(如果不存在)
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function ensureUserData(apiKey) {
|
||||
ensureLoaded();
|
||||
if (!userDataStore.users[apiKey]) {
|
||||
userDataStore.users[apiKey] = {
|
||||
credentials: [],
|
||||
credentialBonuses: [],
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
markDirty();
|
||||
}
|
||||
// 兼容旧数据:添加 credentialBonuses 数组
|
||||
if (!userDataStore.users[apiKey].credentialBonuses) {
|
||||
userDataStore.users[apiKey].credentialBonuses = [];
|
||||
markDirty();
|
||||
}
|
||||
return userDataStore.users[apiKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加凭据路径到用户
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @param {Object} credentialInfo - 凭据信息
|
||||
* @param {string} credentialInfo.path - 凭据文件路径
|
||||
* @param {string} credentialInfo.provider - 提供商类型 (如 'claude-kiro-oauth')
|
||||
* @param {string} [credentialInfo.authMethod] - 认证方式 (如 'builder-id', 'google', 'github')
|
||||
* @returns {Object} 添加的凭据信息
|
||||
*/
|
||||
export async function addUserCredential(apiKey, credentialInfo) {
|
||||
ensureLoaded();
|
||||
const userData = ensureUserData(apiKey);
|
||||
|
||||
// 检查是否已存在相同路径
|
||||
const existingIndex = userData.credentials.findIndex(c => c.path === credentialInfo.path);
|
||||
|
||||
// 只保留核心字段,健康状态从主服务实时获取
|
||||
const credential = {
|
||||
id: `cred_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
|
||||
path: credentialInfo.path,
|
||||
provider: credentialInfo.provider || 'claude-kiro-oauth',
|
||||
authMethod: credentialInfo.authMethod || 'unknown',
|
||||
addedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新已存在的凭据,保留原有 id 和 addedAt
|
||||
credential.id = userData.credentials[existingIndex].id;
|
||||
credential.addedAt = userData.credentials[existingIndex].addedAt;
|
||||
userData.credentials[existingIndex] = credential;
|
||||
} else {
|
||||
userData.credentials.push(credential);
|
||||
}
|
||||
|
||||
markDirty();
|
||||
await persistIfDirty();
|
||||
|
||||
return credential;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除用户凭据
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @param {string} credentialId - 凭据 ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export async function removeUserCredential(apiKey, credentialId) {
|
||||
ensureLoaded();
|
||||
const userData = userDataStore.users[apiKey];
|
||||
if (!userData) return false;
|
||||
|
||||
const index = userData.credentials.findIndex(c => c.id === credentialId);
|
||||
if (index === -1) return false;
|
||||
|
||||
userData.credentials.splice(index, 1);
|
||||
markDirty();
|
||||
await persistIfDirty();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有凭据
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getUserCredentials(apiKey) {
|
||||
ensureLoaded();
|
||||
const userData = userDataStore.users[apiKey];
|
||||
return userData ? userData.credentials : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过路径查找凭据
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @param {string} credPath - 凭据文件路径
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function findCredentialByPath(apiKey, credPath) {
|
||||
ensureLoaded();
|
||||
const userData = userDataStore.users[apiKey];
|
||||
if (!userData) return null;
|
||||
|
||||
return userData.credentials.find(c => c.path === credPath) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查凭据路径是否已被任何用户使用
|
||||
* @param {string} credPath - 凭据文件路径
|
||||
* @returns {{exists: boolean, apiKey?: string}}
|
||||
*/
|
||||
export function isCredentialPathUsed(credPath) {
|
||||
ensureLoaded();
|
||||
for (const [apiKey, userData] of Object.entries(userDataStore.users)) {
|
||||
const found = userData.credentials.find(c => c.path === credPath);
|
||||
if (found) {
|
||||
return { exists: true, apiKey };
|
||||
}
|
||||
}
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移用户凭据到新 Key(用于 Key 重置时)
|
||||
* @param {string} oldApiKey - 旧 API Key
|
||||
* @param {string} newApiKey - 新 API Key
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function migrateUserCredentials(oldApiKey, newApiKey) {
|
||||
ensureLoaded();
|
||||
const oldUserData = userDataStore.users[oldApiKey];
|
||||
if (!oldUserData) return false;
|
||||
|
||||
// 将旧用户数据迁移到新 Key
|
||||
userDataStore.users[newApiKey] = {
|
||||
...oldUserData,
|
||||
migratedFrom: oldApiKey.substring(0, 12) + '...',
|
||||
migratedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 删除旧用户数据
|
||||
delete userDataStore.users[oldApiKey];
|
||||
|
||||
markDirty();
|
||||
await persistIfDirty();
|
||||
|
||||
logger.info(`[API Potluck UserData] Migrated credentials from ${oldApiKey.substring(0, 12)}... to ${newApiKey.substring(0, 12)}...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户及其凭据(用于批量健康检查)
|
||||
* @returns {Array<{apiKey: string, credentials: Array}>}
|
||||
*/
|
||||
export function getAllUsersCredentials() {
|
||||
ensureLoaded();
|
||||
const result = [];
|
||||
for (const [apiKey, userData] of Object.entries(userDataStore.users)) {
|
||||
if (userData.credentials && userData.credentials.length > 0) {
|
||||
result.push({
|
||||
apiKey,
|
||||
credentials: userData.credentials
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ 凭证资源包管理 ============
|
||||
|
||||
/**
|
||||
* 计算资源包过期时间(使用动态配置)
|
||||
* @param {string} grantedAt - 授予时间
|
||||
* @returns {Date}
|
||||
*/
|
||||
function calculateExpiresAt(grantedAt) {
|
||||
const { bonusValidityDays } = getBonusConfig();
|
||||
const granted = new Date(grantedAt);
|
||||
return new Date(granted.getTime() + bonusValidityDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查资源包是否过期
|
||||
* @param {Object} bonus - 资源包对象
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isBonusExpired(bonus) {
|
||||
const expiresAt = calculateExpiresAt(bonus.grantedAt);
|
||||
return new Date() > expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为凭证添加资源包(凭证健康时调用)
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @param {string} credentialId - 凭证 ID
|
||||
* @returns {Object|null} 添加的资源包信息
|
||||
*/
|
||||
export async function addCredentialBonus(apiKey, credentialId) {
|
||||
ensureLoaded();
|
||||
const userData = ensureUserData(apiKey);
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = userData.credentialBonuses.find(b => b.credentialId === credentialId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bonus = {
|
||||
credentialId,
|
||||
grantedAt: new Date().toISOString(),
|
||||
usedCount: 0
|
||||
};
|
||||
|
||||
userData.credentialBonuses.push(bonus);
|
||||
markDirty();
|
||||
|
||||
logger.info(`[API Potluck UserData] Added bonus for credential: ${credentialId}`);
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除凭证资源包(凭证失效时调用)
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @param {string} credentialId - 凭证 ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export async function removeCredentialBonus(apiKey, credentialId) {
|
||||
ensureLoaded();
|
||||
const userData = userDataStore.users[apiKey];
|
||||
if (!userData || !userData.credentialBonuses) return false;
|
||||
|
||||
const index = userData.credentialBonuses.findIndex(b => b.credentialId === credentialId);
|
||||
if (index === -1) return false;
|
||||
|
||||
userData.credentialBonuses.splice(index, 1);
|
||||
markDirty();
|
||||
|
||||
logger.info(`[API Potluck UserData] Removed bonus for credential: ${credentialId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消耗资源包次数(FIFO 顺序,使用 Mutex 保证并发安全)
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @returns {boolean} 是否成功消耗
|
||||
*/
|
||||
export async function consumeBonus(apiKey) {
|
||||
// 使用 Mutex 保证并发安全
|
||||
return bonusMutex.runExclusive(async () => {
|
||||
ensureLoaded();
|
||||
const userData = userDataStore.users[apiKey];
|
||||
if (!userData || !userData.credentialBonuses) return false;
|
||||
|
||||
const { bonusPerCredential } = getBonusConfig();
|
||||
|
||||
// 按 grantedAt 排序(FIFO)
|
||||
const sortedBonuses = userData.credentialBonuses
|
||||
.filter(b => !isBonusExpired(b))
|
||||
.sort((a, b) => new Date(a.grantedAt) - new Date(b.grantedAt));
|
||||
|
||||
// 找到第一个有剩余次数的资源包
|
||||
for (const bonus of sortedBonuses) {
|
||||
const remaining = bonusPerCredential - bonus.usedCount;
|
||||
if (remaining > 0) {
|
||||
bonus.usedCount += 1;
|
||||
markDirty();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算用户的剩余资源包总次数
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @param {Set<string>} [healthyCredentialIds] - 健康凭证 ID 集合(可选,用于过滤)
|
||||
* @returns {number}
|
||||
*/
|
||||
export function calculateBonusRemaining(apiKey, healthyCredentialIds = null) {
|
||||
ensureLoaded();
|
||||
const userData = userDataStore.users[apiKey];
|
||||
if (!userData || !userData.credentialBonuses) return 0;
|
||||
|
||||
const { bonusPerCredential } = getBonusConfig();
|
||||
|
||||
let total = 0;
|
||||
for (const bonus of userData.credentialBonuses) {
|
||||
// 检查是否过期
|
||||
if (isBonusExpired(bonus)) continue;
|
||||
|
||||
// 如果提供了健康凭证集合,检查凭证是否健康
|
||||
if (healthyCredentialIds && !healthyCredentialIds.has(bonus.credentialId)) continue;
|
||||
|
||||
const remaining = bonusPerCredential - bonus.usedCount;
|
||||
if (remaining > 0) {
|
||||
total += remaining;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步资源包状态(根据健康凭证列表)
|
||||
* 兼容历史数据:为已有健康凭证创建资源包,使用凭证的 addedAt 作为 grantedAt
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @param {Array<{id: string, isHealthy: boolean, addedAt?: string}>} credentialsWithHealth - 带健康状态的凭证列表
|
||||
* @returns {{added: number, removed: number, bonusRemaining: number}}
|
||||
*/
|
||||
export async function syncCredentialBonuses(apiKey, credentialsWithHealth) {
|
||||
ensureLoaded();
|
||||
const userData = ensureUserData(apiKey);
|
||||
|
||||
let added = 0, removed = 0;
|
||||
|
||||
// 获取健康凭证 ID 集合
|
||||
const healthyIds = new Set(
|
||||
credentialsWithHealth
|
||||
.filter(c => c.isHealthy === true)
|
||||
.map(c => c.id)
|
||||
);
|
||||
|
||||
// 为新的健康凭证添加资源包
|
||||
for (const cred of credentialsWithHealth) {
|
||||
if (cred.isHealthy !== true) continue;
|
||||
|
||||
const exists = userData.credentialBonuses.some(b => b.credentialId === cred.id);
|
||||
if (!exists) {
|
||||
// 使用凭证的 addedAt 作为资源包授予时间(兼容历史数据)
|
||||
const grantedAt = cred.addedAt || new Date().toISOString();
|
||||
userData.credentialBonuses.push({
|
||||
credentialId: cred.id,
|
||||
grantedAt: grantedAt,
|
||||
usedCount: 0
|
||||
});
|
||||
added++;
|
||||
logger.info(`[API Potluck UserData] Created bonus for credential ${cred.id}, grantedAt: ${grantedAt}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除失效凭证的资源包
|
||||
const toRemove = userData.credentialBonuses.filter(b => !healthyIds.has(b.credentialId));
|
||||
for (const bonus of toRemove) {
|
||||
const idx = userData.credentialBonuses.indexOf(bonus);
|
||||
if (idx !== -1) {
|
||||
userData.credentialBonuses.splice(idx, 1);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期资源包
|
||||
const expiredCount = userData.credentialBonuses.filter(b => isBonusExpired(b)).length;
|
||||
userData.credentialBonuses = userData.credentialBonuses.filter(b => !isBonusExpired(b));
|
||||
|
||||
if (added > 0 || removed > 0 || expiredCount > 0) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// 计算剩余资源包次数
|
||||
const bonusRemaining = calculateBonusRemaining(apiKey, healthyIds);
|
||||
|
||||
return { added, removed, bonusRemaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的资源包详情
|
||||
* @param {string} apiKey - 用户的 API Key
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getBonusDetails(apiKey) {
|
||||
ensureLoaded();
|
||||
const { bonusPerCredential, bonusValidityDays } = getBonusConfig();
|
||||
const userData = userDataStore.users[apiKey];
|
||||
if (!userData) {
|
||||
return {
|
||||
bonuses: [],
|
||||
totalRemaining: 0,
|
||||
bonusPerCredential,
|
||||
validityDays: bonusValidityDays
|
||||
};
|
||||
}
|
||||
|
||||
const bonuses = (userData.credentialBonuses || [])
|
||||
.filter(b => !isBonusExpired(b))
|
||||
.map(b => ({
|
||||
credentialId: b.credentialId,
|
||||
grantedAt: b.grantedAt,
|
||||
expiresAt: calculateExpiresAt(b.grantedAt).toISOString(),
|
||||
usedCount: b.usedCount,
|
||||
remaining: bonusPerCredential - b.usedCount
|
||||
}));
|
||||
|
||||
const totalRemaining = bonuses.reduce((sum, b) => sum + Math.max(0, b.remaining), 0);
|
||||
|
||||
return {
|
||||
bonuses,
|
||||
totalRemaining,
|
||||
bonusPerCredential,
|
||||
validityDays: bonusValidityDays
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户的 API Key 列表
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getAllUserApiKeys() {
|
||||
ensureLoaded();
|
||||
return Object.keys(userDataStore.users);
|
||||
}
|
||||
|
||||
export { USER_DATA_FILE };
|
||||
// 空模块,保留文件以避免导入错误
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { promises as fs } from 'fs';
|
|||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { CONFIG } from '../core/config-manager.js';
|
||||
import { getClientIp } from '../utils/common.js';
|
||||
|
||||
// Token存储到本地文件中
|
||||
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
|
||||
|
|
@ -163,6 +164,84 @@ async function deleteToken(token) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理登录尝试频率和锁定
|
||||
*/
|
||||
class LoginAttemptManager {
|
||||
constructor() {
|
||||
this.attempts = new Map(); // IP -> { count, lastAttempt, lockoutUntil }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 IP 的状态
|
||||
*/
|
||||
getIpStatus(ip) {
|
||||
if (!this.attempts.has(ip)) {
|
||||
this.attempts.set(ip, { count: 0, lastAttempt: 0, lockoutUntil: 0 });
|
||||
}
|
||||
return this.attempts.get(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否被锁定
|
||||
*/
|
||||
isLockedOut(ip) {
|
||||
const status = this.getIpStatus(ip);
|
||||
if (status.lockoutUntil > Date.now()) {
|
||||
return {
|
||||
locked: true,
|
||||
remainingTime: Math.ceil((status.lockoutUntil - Date.now()) / 1000)
|
||||
};
|
||||
}
|
||||
// 如果锁定时间已过,重置失败次数
|
||||
if (status.lockoutUntil > 0 && status.lockoutUntil <= Date.now()) {
|
||||
status.count = 0;
|
||||
status.lockoutUntil = 0;
|
||||
}
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否请求过于频繁
|
||||
*/
|
||||
isTooFrequent(ip) {
|
||||
const status = this.getIpStatus(ip);
|
||||
const minInterval = CONFIG.LOGIN_MIN_INTERVAL || 1000;
|
||||
const now = Date.now();
|
||||
if (now - status.lastAttempt < minInterval) {
|
||||
return true;
|
||||
}
|
||||
status.lastAttempt = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次失败
|
||||
*/
|
||||
recordFailure(ip) {
|
||||
const status = this.getIpStatus(ip);
|
||||
status.count++;
|
||||
const maxAttempts = CONFIG.LOGIN_MAX_ATTEMPTS || 5;
|
||||
const lockoutDuration = (CONFIG.LOGIN_LOCKOUT_DURATION || 1800) * 1000;
|
||||
|
||||
if (status.count >= maxAttempts) {
|
||||
status.lockoutUntil = Date.now() + lockoutDuration;
|
||||
logger.warn(`[Auth] IP ${ip} locked out due to too many failed login attempts (${status.count})`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功后重置
|
||||
*/
|
||||
reset(ip) {
|
||||
this.attempts.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
const loginAttemptManager = new LoginAttemptManager();
|
||||
|
||||
/**
|
||||
* 清理过期的token
|
||||
*/
|
||||
|
|
@ -205,7 +284,39 @@ export async function checkAuth(req) {
|
|||
export async function handleLoginRequest(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'Only POST requests are supported' }));
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Only POST requests are supported',
|
||||
messageCode: 'login.error.postOnly'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
|
||||
// 1. 检查锁定状态
|
||||
const lockout = loginAttemptManager.isLockedOut(ip);
|
||||
if (lockout.locked) {
|
||||
logger.warn(`[Auth] Login attempt from locked IP: ${ip}, reason: account_locked, remaining: ${lockout.remainingTime}s`);
|
||||
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
message: `Account temporarily locked due to too many failed attempts. Please try again in ${lockout.remainingTime} seconds.`,
|
||||
messageCode: 'login.error.locked',
|
||||
messageParams: { time: lockout.remainingTime }
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 频率限制
|
||||
if (loginAttemptManager.isTooFrequent(ip)) {
|
||||
logger.warn(`[Auth] Login attempt too frequent from IP: ${ip}, reason: rate_limit`);
|
||||
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Too many requests, please slow down.',
|
||||
messageCode: 'login.error.tooFrequent'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -214,17 +325,27 @@ export async function handleLoginRequest(req, res) {
|
|||
const { password } = requestData;
|
||||
|
||||
if (!password) {
|
||||
logger.warn(`[Auth] Login failed from IP: ${ip}, reason: empty_password`);
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'Password cannot be empty' }));
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Password cannot be empty',
|
||||
messageCode: 'login.error.empty'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
const isValid = await validateCredentials(password);
|
||||
|
||||
if (isValid) {
|
||||
logger.info(`[Auth] Login successful from IP: ${ip}`);
|
||||
// 登录成功,重置计数
|
||||
loginAttemptManager.reset(ip);
|
||||
|
||||
// Generate simple token
|
||||
const token = generateToken();
|
||||
const expiryTime = getExpiryTime();
|
||||
const loginExpiry = CONFIG.LOGIN_EXPIRY || 3600;
|
||||
const expiryTime = Date.now() + (loginExpiry * 1000);
|
||||
|
||||
// Store token info to local file
|
||||
await saveToken(token, {
|
||||
|
|
@ -238,21 +359,37 @@ export async function handleLoginRequest(req, res) {
|
|||
success: true,
|
||||
message: 'Login successful',
|
||||
token,
|
||||
expiresIn: `${CONFIG.LOGIN_EXPIRY || 3600} seconds`
|
||||
expiresIn: `${loginExpiry} seconds`
|
||||
}));
|
||||
} else {
|
||||
// 登录失败,记录
|
||||
const isLocked = loginAttemptManager.recordFailure(ip);
|
||||
const status = loginAttemptManager.getIpStatus(ip);
|
||||
const maxAttempts = CONFIG.LOGIN_MAX_ATTEMPTS || 5;
|
||||
const remaining = maxAttempts - status.count;
|
||||
const lockoutDuration = CONFIG.LOGIN_LOCKOUT_DURATION || 1800;
|
||||
|
||||
logger.warn(`[Auth] Login failed from IP: ${ip}, reason: incorrect_password, remaining_attempts: ${Math.max(0, remaining)}${isLocked ? ', result: locked' : ''}`);
|
||||
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
message: 'Incorrect password, please try again'
|
||||
message: isLocked
|
||||
? `Incorrect password. Account locked for ${Math.ceil(lockoutDuration / 60)} minutes.`
|
||||
: `Incorrect password. ${remaining} attempts remaining.`,
|
||||
messageCode: isLocked ? 'login.error.incorrectWithLock' : 'login.error.incorrectWithRemaining',
|
||||
messageParams: isLocked ? { time: Math.ceil(lockoutDuration / 60) } : { count: remaining }
|
||||
}));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[Auth] Login processing error:', error);
|
||||
const isJsonError = error.message === 'Invalid JSON format';
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
message: error.message || 'Server error'
|
||||
message: error.message || 'Server error',
|
||||
messageCode: isJsonError ? 'login.error.invalidJson' : undefined
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -77,51 +77,96 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) {
|
|||
|
||||
// 读取文件内容进行分析
|
||||
let content = '';
|
||||
let type = 'oauth_credentials';
|
||||
let type = 'oauth'; // 默认为 oauth 类型
|
||||
let isValid = true;
|
||||
let errorMessage = '';
|
||||
let oauthProvider = 'unknown';
|
||||
let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig);
|
||||
|
||||
// 从路径预检测提供商
|
||||
const normalizedPath = relativePath.replace(/\\/g, '/').toLowerCase();
|
||||
if (normalizedPath.includes('/kiro/')) oauthProvider = 'kiro';
|
||||
else if (normalizedPath.includes('/gemini/')) oauthProvider = 'gemini';
|
||||
else if (normalizedPath.includes('/qwen/')) oauthProvider = 'qwen';
|
||||
else if (normalizedPath.includes('/antigravity/')) oauthProvider = 'antigravity';
|
||||
else if (normalizedPath.includes('/codex/')) oauthProvider = 'codex';
|
||||
else if (normalizedPath.includes('/iflow/')) oauthProvider = 'iflow';
|
||||
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// 1. 首先尝试根据文件名识别特定类型的配置 (最高优先级)
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
if (lowerFilename === 'provider_pools.json' || lowerFilename === 'provider-pools.json') {
|
||||
type = 'provider-pool';
|
||||
} else if (lowerFilename.includes('system_prompt') || lowerFilename.includes('system-prompt')) {
|
||||
type = 'system-prompt';
|
||||
} else if (lowerFilename === 'plugins.json') {
|
||||
type = 'plugins';
|
||||
} else if (lowerFilename === 'usage-cache.json') {
|
||||
type = 'usage';
|
||||
} else if (lowerFilename === 'config.json') {
|
||||
type = 'config';
|
||||
} else if (lowerFilename.includes('potluck-keys')) {
|
||||
type = 'api-key';
|
||||
} else if (lowerFilename.includes('potluck-data')) {
|
||||
type = 'database';
|
||||
} else if (lowerFilename === 'token-store.json') {
|
||||
type = 'oauth';
|
||||
}
|
||||
|
||||
// 2. 根据内容进一步识别和完善信息
|
||||
if (ext === '.json') {
|
||||
const rawContent = await fs.readFile(filePath, 'utf8');
|
||||
const jsonData = JSON.parse(rawContent);
|
||||
content = rawContent;
|
||||
|
||||
// 识别OAuth提供商
|
||||
if (jsonData.apiKey || jsonData.api_key) {
|
||||
type = 'api_key';
|
||||
} else if (jsonData.client_id || jsonData.client_secret) {
|
||||
oauthProvider = 'oauth2';
|
||||
} else if (jsonData.access_token || jsonData.refresh_token) {
|
||||
oauthProvider = 'token_based';
|
||||
} else if (jsonData.credentials) {
|
||||
oauthProvider = 'service_account';
|
||||
}
|
||||
|
||||
if (jsonData.base_url || jsonData.endpoint) {
|
||||
if (jsonData.base_url.includes('openai.com')) {
|
||||
oauthProvider = 'openai';
|
||||
} else if (jsonData.base_url.includes('anthropic.com')) {
|
||||
oauthProvider = 'claude';
|
||||
} else if (jsonData.base_url.includes('googleapis.com')) {
|
||||
oauthProvider = 'gemini';
|
||||
try {
|
||||
const jsonData = JSON.parse(content);
|
||||
|
||||
// 如果文件名没识别出类型,尝试从内容识别
|
||||
if (type === 'oauth') {
|
||||
if (jsonData.providerPools || jsonData.provider_pools) {
|
||||
type = 'provider-pool';
|
||||
} else if (jsonData.apiKey || jsonData.api_key) {
|
||||
type = 'api-key';
|
||||
}
|
||||
}
|
||||
|
||||
// 识别具体的提供商/认证方式
|
||||
if (jsonData.client_id || jsonData.client_secret) {
|
||||
if (oauthProvider === 'unknown') oauthProvider = 'oauth2';
|
||||
} else if (jsonData.access_token || jsonData.refresh_token) {
|
||||
if (oauthProvider === 'unknown') oauthProvider = 'token_based';
|
||||
} else if (jsonData.credentials) {
|
||||
if (oauthProvider === 'unknown') oauthProvider = 'service_account';
|
||||
} else if (jsonData.apiKey || jsonData.api_key) {
|
||||
if (oauthProvider === 'unknown') oauthProvider = 'api_key';
|
||||
}
|
||||
|
||||
if (jsonData.base_url || jsonData.endpoint) {
|
||||
const baseUrl = (jsonData.base_url || jsonData.endpoint).toLowerCase();
|
||||
if (baseUrl.includes('openai.com')) {
|
||||
oauthProvider = 'openai';
|
||||
} else if (baseUrl.includes('anthropic.com')) {
|
||||
oauthProvider = 'claude';
|
||||
} else if (baseUrl.includes('googleapis.com')) {
|
||||
oauthProvider = 'gemini';
|
||||
}
|
||||
}
|
||||
} catch (jsonErr) {
|
||||
isValid = false;
|
||||
errorMessage = `JSON Parse Error: ${jsonErr.message}`;
|
||||
}
|
||||
} else {
|
||||
content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// 处理非 JSON 文件
|
||||
if (ext === '.key' || ext === '.pem') {
|
||||
if (content.includes('-----BEGIN') && content.includes('PRIVATE KEY-----')) {
|
||||
oauthProvider = 'private_key';
|
||||
}
|
||||
} else if (ext === '.txt') {
|
||||
if (content.includes('api_key') || content.includes('apikey')) {
|
||||
oauthProvider = 'api_key';
|
||||
if (type === 'oauth') type = 'api-key';
|
||||
if (oauthProvider === 'unknown') oauthProvider = 'api_key';
|
||||
}
|
||||
} else if (ext === '.oauth' || ext === '.creds') {
|
||||
oauthProvider = 'oauth_credentials';
|
||||
if (oauthProvider === 'unknown') oauthProvider = 'oauth_credentials';
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
|
|
@ -133,14 +178,14 @@ async function analyzeOAuthFile(filePath, usedPaths, currentConfig) {
|
|||
name: filename,
|
||||
path: relativePath,
|
||||
size: stats.size,
|
||||
type: type,
|
||||
type: type, // 用于前端图标显示的关键字段
|
||||
provider: oauthProvider,
|
||||
extension: ext,
|
||||
modified: stats.mtime.toISOString(),
|
||||
isValid: isValid,
|
||||
errorMessage: errorMessage,
|
||||
isUsed: isPathUsed(relativePath, filename, usedPaths),
|
||||
usageInfo: usageInfo, // 新增详细关联信息
|
||||
usageInfo: usageInfo,
|
||||
preview: content.substring(0, 100) + (content.length > 100 ? '...' : '')
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -988,21 +988,19 @@ export async function handleContentGenerationRequest(req, res, service, endpoint
|
|||
await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName, retryContext);
|
||||
}
|
||||
|
||||
if (CONFIG?._monitorRequestId) {
|
||||
// 执行插件钩子:内容生成后
|
||||
try {
|
||||
const pluginManager = getPluginManager();
|
||||
await pluginManager.executeHook('onContentGenerated', {
|
||||
...CONFIG,
|
||||
originalRequestBody,
|
||||
processedRequestBody,
|
||||
fromProvider,
|
||||
toProvider,
|
||||
model,
|
||||
isStream
|
||||
});
|
||||
} catch (e) { /* 静默失败,不影响主流程 */ }
|
||||
}
|
||||
// 执行插件钩子:内容生成后
|
||||
try {
|
||||
const pluginManager = getPluginManager();
|
||||
await pluginManager.executeHook('onContentGenerated', {
|
||||
...CONFIG,
|
||||
originalRequestBody,
|
||||
processedRequestBody,
|
||||
fromProvider,
|
||||
toProvider,
|
||||
model,
|
||||
isStream
|
||||
});
|
||||
} catch (e) { /* 静默失败,不影响主流程 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
/**
|
||||
* 统一日志工具类
|
||||
|
|
@ -20,7 +21,7 @@ class Logger {
|
|||
};
|
||||
this.currentLogFile = null;
|
||||
this.logStream = null;
|
||||
this.currentRequestId = null; // 当前请求ID
|
||||
this.asyncStorage = new AsyncLocalStorage(); // 使用 AsyncLocalStorage 存储请求上下文
|
||||
this.requestContext = new Map(); // 存储请求上下文
|
||||
this.contextTTL = 5 * 60 * 1000; // 请求上下文 TTL:5 分钟
|
||||
this._contextCleanupTimer = null;
|
||||
|
|
@ -32,6 +33,7 @@ class Logger {
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 初始化日志配置
|
||||
* @param {Object} config - 日志配置对象
|
||||
|
|
@ -80,7 +82,22 @@ class Logger {
|
|||
}
|
||||
|
||||
/**
|
||||
* 设置请求上下文
|
||||
* 在请求上下文中运行
|
||||
* @param {string} requestId - 请求ID
|
||||
* @param {Function} callback - 回调函数
|
||||
* @returns {any}
|
||||
*/
|
||||
runWithContext(requestId, callback) {
|
||||
if (!requestId) {
|
||||
requestId = randomUUID().substring(0, 8);
|
||||
}
|
||||
this.requestContext.set(requestId, { _createdAt: Date.now() });
|
||||
this._ensureContextCleanup();
|
||||
return this.asyncStorage.run(requestId, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求上下文 (不推荐直接使用,建议使用 runWithContext)
|
||||
* @param {string} requestId - 请求ID
|
||||
* @param {Object} context - 上下文信息
|
||||
*/
|
||||
|
|
@ -88,7 +105,7 @@ class Logger {
|
|||
if (!requestId) {
|
||||
requestId = randomUUID().substring(0, 8);
|
||||
}
|
||||
this.currentRequestId = requestId;
|
||||
this.asyncStorage.enterWith(requestId);
|
||||
this.requestContext.set(requestId, { ...context, _createdAt: Date.now() });
|
||||
this._ensureContextCleanup();
|
||||
return requestId;
|
||||
|
|
@ -99,8 +116,8 @@ class Logger {
|
|||
* @returns {string} 请求ID
|
||||
*/
|
||||
getCurrentRequestId() {
|
||||
// 从上下文中获取当前请求ID
|
||||
return this.currentRequestId;
|
||||
// 从 AsyncLocalStorage 中获取当前请求ID
|
||||
return this.asyncStorage.getStore();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -109,6 +126,9 @@ class Logger {
|
|||
* @returns {Object} 上下文信息
|
||||
*/
|
||||
getRequestContext(requestId) {
|
||||
if (!requestId) {
|
||||
requestId = this.getCurrentRequestId();
|
||||
}
|
||||
return this.requestContext.get(requestId) || {};
|
||||
}
|
||||
|
||||
|
|
@ -120,9 +140,11 @@ class Logger {
|
|||
if (requestId) {
|
||||
this.requestContext.delete(requestId);
|
||||
}
|
||||
this.currentRequestId = null;
|
||||
// AsyncLocalStorage 不需要手动清除,run() 会在结束时自动处理
|
||||
// 如果使用了 enterWith,则没有简单的方法在该异步路径中清除
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 启动定期清理过期请求上下文的定时器(防止内存泄漏)
|
||||
* 每 60 秒扫描一次,清除超过 contextTTL 的条目
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ import {
|
|||
|
||||
import {
|
||||
loadConfiguration,
|
||||
saveConfiguration
|
||||
saveConfiguration,
|
||||
generateApiKey
|
||||
} from './config-manager.js';
|
||||
|
||||
import {
|
||||
|
|
@ -241,6 +242,7 @@ window.loadConfigList = loadConfigList;
|
|||
window.closeConfigModal = closeConfigModal;
|
||||
window.copyConfigContent = copyConfigContent;
|
||||
window.reloadConfig = reloadConfig;
|
||||
window.generateApiKey = generateApiKey;
|
||||
|
||||
// 用量管理相关全局函数
|
||||
window.refreshUsage = refreshUsage;
|
||||
|
|
|
|||
|
|
@ -348,8 +348,30 @@ async function saveConfiguration() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动生成 API 密钥
|
||||
*/
|
||||
function generateApiKey() {
|
||||
const apiKeyEl = document.getElementById('apiKey');
|
||||
if (!apiKeyEl) return;
|
||||
|
||||
// 生成 32 位 16 进制随机字符串
|
||||
const array = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(array);
|
||||
const randomKey = 'sk-' + Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
apiKeyEl.value = randomKey;
|
||||
|
||||
showToast(t('common.success'), t('config.apiKey.generated') || '已生成新的 API 密钥', 'success');
|
||||
|
||||
// 触发输入框的 change 事件
|
||||
apiKeyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
apiKeyEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
export {
|
||||
loadConfiguration,
|
||||
saveConfiguration,
|
||||
updateConfigProviderConfigs
|
||||
updateConfigProviderConfigs,
|
||||
generateApiKey
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js';
|
||||
import { showToast } from './utils.js';
|
||||
import { fileUploadHandler } from './file-upload.js';
|
||||
import { t } from './i18n.js';
|
||||
import { checkUpdate, performUpdate } from './provider-manager.js';
|
||||
|
||||
|
|
@ -151,7 +150,13 @@ function initEventListeners() {
|
|||
|
||||
// 保存配置
|
||||
if (elements.saveConfigBtn) {
|
||||
elements.saveConfigBtn.addEventListener('click', saveConfiguration);
|
||||
elements.saveConfigBtn.addEventListener('click', () => {
|
||||
if (window.saveConfiguration) {
|
||||
window.saveConfiguration();
|
||||
} else if (saveConfiguration) {
|
||||
saveConfiguration();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
|
|
@ -179,6 +184,18 @@ function initEventListeners() {
|
|||
button.addEventListener('click', handlePasswordToggle);
|
||||
});
|
||||
|
||||
// 生成 API 密钥按钮监听
|
||||
const generateApiKeyBtn = document.getElementById('generateApiKey');
|
||||
if (generateApiKeyBtn) {
|
||||
generateApiKeyBtn.addEventListener('click', () => {
|
||||
if (window.generateApiKey) {
|
||||
window.generateApiKey();
|
||||
} else {
|
||||
console.error('generateApiKey function not found');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 生成凭据按钮监听
|
||||
document.querySelectorAll('.generate-creds-btn').forEach(button => {
|
||||
button.addEventListener('click', handleGenerateCreds);
|
||||
|
|
|
|||
|
|
@ -212,6 +212,9 @@ const translations = {
|
|||
// Config
|
||||
'config.title': '配置管理',
|
||||
'config.apiKey': 'API密钥',
|
||||
'config.apiKey.generate': '生成',
|
||||
'config.apiKey.generateTitle': '自动生成API密钥',
|
||||
'config.apiKey.generated': '已生成新的 API 密钥',
|
||||
'config.apiKeyPlaceholder': '请输入API密钥',
|
||||
'config.host': '监听地址',
|
||||
'config.hostPlaceholder': '例如: 127.0.0.1',
|
||||
|
|
@ -291,7 +294,7 @@ const translations = {
|
|||
'config.advanced.loginExpiryNote': '管理后台登录后的 Token 有效期,默认 3600 秒 (1小时)',
|
||||
'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)',
|
||||
'config.advanced.poolFilePathPlaceholder': '默认: configs/provider_pools.json',
|
||||
'config.advanced.poolNote': '使用默认路径配置需添加一个空节点',
|
||||
'config.advanced.poolNote': '如使用客户端默认授权配置需使用空节点',
|
||||
'config.advanced.maxErrorCount': '提供商最大错误次数',
|
||||
'config.advanced.maxErrorCountPlaceholder': '默认: 10',
|
||||
'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 10 次',
|
||||
|
|
@ -423,7 +426,7 @@ const translations = {
|
|||
|
||||
// Providers
|
||||
'providers.title': '提供商池管理',
|
||||
'providers.note': '使用默认路径配置需添加一个空节点',
|
||||
'providers.note': '如使用客户端默认授权配置需使用空节点',
|
||||
'providers.activeConnections': '活动连接',
|
||||
'providers.activeProviders': '活跃提供商',
|
||||
'providers.healthyProviders': '健康提供商',
|
||||
|
|
@ -693,8 +696,12 @@ const translations = {
|
|||
'guide.flow.step2.method2.item1': '新增提供商节点',
|
||||
'guide.flow.step2.method2.item2': '上传已有的授权文件',
|
||||
'guide.flow.step2.method2.item3': '手动关联凭据路径',
|
||||
'guide.flow.step2.method3': '方式三:对接提供商 API',
|
||||
'guide.flow.step2.method3.item1': '在「提供商池管理」新增对应协议的节点',
|
||||
'guide.flow.step2.method3.item2': '填入 API Key 和端点 (Base URL)',
|
||||
'guide.flow.step2.method3.item3': '无需生成或上传 OAuth 授权文件',
|
||||
'guide.flow.step3.title': '管理凭据',
|
||||
'guide.flow.step3.desc': '在「凭据文件管理」页面查看和管理凭据',
|
||||
'guide.flow.step3.desc': '在「凭据文件管理」页面查看和管理凭据(非授权提供商可忽略)',
|
||||
'guide.flow.step3.item1': '查看已生成的凭据文件',
|
||||
'guide.flow.step3.item2': '自动关联到提供商池',
|
||||
'guide.flow.step3.item3': '删除无效凭据',
|
||||
|
|
@ -806,6 +813,12 @@ const translations = {
|
|||
'login.passwordPlaceholder': '请输入密码',
|
||||
'login.error.empty': '请输入密码',
|
||||
'login.error.incorrect': '密码错误,请重试',
|
||||
'login.error.incorrectWithLock': '密码错误。账户已被锁定 {time} 分钟。',
|
||||
'login.error.incorrectWithRemaining': '密码错误。还剩 {count} 次尝试机会。',
|
||||
'login.error.locked': '账户因多次尝试失败而被暂时锁定。请在 {time} 秒后重试。',
|
||||
'login.error.tooFrequent': '请求过于频繁,请稍候再试。',
|
||||
'login.error.postOnly': '仅支持 POST 请求',
|
||||
'login.error.invalidJson': '请求格式错误 (JSON)',
|
||||
'login.error.failed': '登录失败,请检查网络连接',
|
||||
'login.button': '登录',
|
||||
'login.loggingIn': '登录中...',
|
||||
|
|
@ -1022,6 +1035,9 @@ const translations = {
|
|||
// Config
|
||||
'config.title': 'Configuration Management',
|
||||
'config.apiKey': 'API Key',
|
||||
'config.apiKey.generate': 'Generate',
|
||||
'config.apiKey.generateTitle': 'Automatically generate API key',
|
||||
'config.apiKey.generated': 'New API key generated',
|
||||
'config.apiKeyPlaceholder': 'Please enter API key',
|
||||
'config.host': 'Listen Address',
|
||||
'config.hostPlaceholder': 'e.g.: 127.0.0.1',
|
||||
|
|
@ -1101,7 +1117,7 @@ const translations = {
|
|||
'config.advanced.loginExpiryNote': 'Token validity period after management console login, default 3600 seconds (1 hour)',
|
||||
'config.advanced.poolFilePath': 'Provider Pool Config File Path (required)',
|
||||
'config.advanced.poolFilePathPlaceholder': 'Default: configs/provider_pools.json',
|
||||
'config.advanced.poolNote': 'To use default path configuration, add an empty node',
|
||||
'config.advanced.poolNote': 'If using default client authorization config, use an empty node',
|
||||
'config.advanced.maxErrorCount': 'Provider Max Error Count',
|
||||
'config.advanced.maxErrorCountPlaceholder': 'Default: 10',
|
||||
'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 10',
|
||||
|
|
@ -1233,7 +1249,7 @@ const translations = {
|
|||
|
||||
// Providers
|
||||
'providers.title': 'Provider Pool Management',
|
||||
'providers.note': 'To use default path configuration, add an empty node',
|
||||
'providers.note': 'If using default client authorization config, use an empty node',
|
||||
'providers.activeConnections': 'Active Connections',
|
||||
'providers.activeProviders': 'Active Providers',
|
||||
'providers.healthyProviders': 'Healthy Providers',
|
||||
|
|
@ -1503,8 +1519,12 @@ const translations = {
|
|||
'guide.flow.step2.method2.item1': 'Add new provider node',
|
||||
'guide.flow.step2.method2.item2': 'Upload existing authorization file',
|
||||
'guide.flow.step2.method2.item3': 'Manually link credential path',
|
||||
'guide.flow.step2.method3': 'Method 3: Provider API Integration',
|
||||
'guide.flow.step2.method3.item1': 'Add node for corresponding protocol in "Provider Pools"',
|
||||
'guide.flow.step2.method3.item2': 'Enter API Key and Endpoint (Base URL)',
|
||||
'guide.flow.step2.method3.item3': 'No OAuth credential file generation/upload required',
|
||||
'guide.flow.step3.title': 'Manage Credentials',
|
||||
'guide.flow.step3.desc': 'View and manage credentials in "Credential Files" page',
|
||||
'guide.flow.step3.desc': 'View and manage credentials in "Credential Files" (Ignore for non-OAuth providers)',
|
||||
'guide.flow.step3.item1': 'View generated credential files',
|
||||
'guide.flow.step3.item2': 'Auto-link to provider pool',
|
||||
'guide.flow.step3.item3': 'Delete invalid credentials',
|
||||
|
|
@ -1616,6 +1636,12 @@ const translations = {
|
|||
'login.passwordPlaceholder': 'Please enter password',
|
||||
'login.error.empty': 'Please enter password',
|
||||
'login.error.incorrect': 'Incorrect password, please try again',
|
||||
'login.error.incorrectWithLock': 'Incorrect password. Account locked for {time} minutes.',
|
||||
'login.error.incorrectWithRemaining': 'Incorrect password. {count} attempts remaining.',
|
||||
'login.error.locked': 'Account temporarily locked due to too many failed attempts. Please try again in {time} seconds.',
|
||||
'login.error.tooFrequent': 'Too many requests, please slow down.',
|
||||
'login.error.postOnly': 'Only POST requests are supported',
|
||||
'login.error.invalidJson': 'Invalid request format (JSON)',
|
||||
'login.error.failed': 'Login failed, please check your network connection',
|
||||
'login.button': 'Login',
|
||||
'login.loggingIn': 'Logging in...',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ let isLoadingConfigs = false; // 防止重复加载配置
|
|||
* @param {string} statusFilter - 状态过滤
|
||||
*/
|
||||
function searchConfigs(searchTerm = '', statusFilter = '', providerFilter = '') {
|
||||
// 确保 searchTerm 是字符串,防止事件对象等非字符串被传入
|
||||
if (typeof searchTerm !== 'string') {
|
||||
searchTerm = '';
|
||||
}
|
||||
|
||||
if (!allConfigs.length) {
|
||||
console.log('没有配置数据可搜索');
|
||||
return;
|
||||
|
|
@ -88,7 +93,11 @@ function createConfigItemElement(config, index) {
|
|||
const typeIcon = config.type === 'oauth' ? 'fa-key' :
|
||||
config.type === 'api-key' ? 'fa-lock' :
|
||||
config.type === 'provider-pool' ? 'fa-network-wired' :
|
||||
config.type === 'system-prompt' ? 'fa-file-text' : 'fa-file-code';
|
||||
config.type === 'system-prompt' ? 'fa-file-text' :
|
||||
config.type === 'plugins' ? 'fa-plug' :
|
||||
config.type === 'usage' ? 'fa-chart-line' :
|
||||
config.type === 'config' ? 'fa-cog' :
|
||||
config.type === 'database' ? 'fa-database' : 'fa-file-code';
|
||||
|
||||
// 检测提供商信息
|
||||
const providerInfo = detectProviderFromPath(config.path);
|
||||
|
|
@ -453,6 +462,11 @@ function updateStats() {
|
|||
* @param {string} providerFilter - 提供商过滤
|
||||
*/
|
||||
async function loadConfigList(searchTerm = '', statusFilter = '', providerFilter = '') {
|
||||
// 确保 searchTerm 是字符串,处理事件监听器直接调用的情况
|
||||
if (typeof searchTerm !== 'string') {
|
||||
searchTerm = '';
|
||||
}
|
||||
|
||||
// 防止重复加载
|
||||
if (isLoadingConfigs) {
|
||||
console.log('正在加载配置列表,跳过重复调用');
|
||||
|
|
@ -912,7 +926,7 @@ function initUploadConfigManager() {
|
|||
}
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', loadConfigList);
|
||||
refreshBtn.addEventListener('click', () => loadConfigList());
|
||||
}
|
||||
|
||||
if (downloadAllBtn) {
|
||||
|
|
|
|||
|
|
@ -55,9 +55,25 @@ textarea.form-control {
|
|||
}
|
||||
|
||||
.password-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-with-toggle {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.generate-key-btn {
|
||||
flex-shrink: 0;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.password-input-wrapper .form-control {
|
||||
|
|
|
|||
|
|
@ -10,9 +10,14 @@
|
|||
<div class="form-group password-input-group">
|
||||
<label for="apiKey" data-i18n="config.apiKey">API密钥</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input type="password" id="apiKey" class="form-control" data-i18n="config.apiKeyPlaceholder" placeholder="请输入API密钥" autocomplete="off">
|
||||
<button type="button" class="password-toggle" data-target="apiKey" aria-label="显示/隐藏密码">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
<div class="input-with-toggle">
|
||||
<input type="password" id="apiKey" class="form-control" data-i18n="config.apiKeyPlaceholder" placeholder="请输入API密钥" autocomplete="off">
|
||||
<button type="button" class="password-toggle" data-target="apiKey" aria-label="显示/隐藏密码">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary generate-key-btn" id="generateApiKey" data-i18n-title="config.apiKey.generateTitle" title="自动生成API密钥">
|
||||
<i class="fas fa-magic"></i> <span data-i18n="config.apiKey.generate">生成</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -318,10 +323,12 @@
|
|||
<div class="form-group">
|
||||
<label for="adminPassword" data-i18n="config.advanced.adminPassword">后台登录密码</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input type="password" id="adminPassword" class="form-control" autocomplete="new-password">
|
||||
<button type="button" class="password-toggle" data-target="adminPassword" aria-label="显示/隐藏密码">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="input-with-toggle">
|
||||
<input type="password" id="adminPassword" class="form-control" autocomplete="new-password">
|
||||
<button type="button" class="password-toggle" data-target="adminPassword" aria-label="显示/隐藏密码">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text" data-i18n="config.advanced.adminPasswordNote">修改后需要重新登录</small>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@
|
|||
<li data-i18n="guide.flow.step2.method2.item3">手动关联凭据路径</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="branch-divider" data-i18n="guide.flow.step2.or">或</div>
|
||||
<div class="branch-option">
|
||||
<div class="branch-label" data-i18n="guide.flow.step2.method3">方式三:对接提供商 API</div>
|
||||
<ul>
|
||||
<li data-i18n="guide.flow.step2.method3.item1">在「配置管理」设置 API Key 和端点</li>
|
||||
<li data-i18n="guide.flow.step2.method3.item2">系统自动识别并对接</li>
|
||||
<li data-i18n="guide.flow.step2.method3.item3">无需手动上传凭据</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -169,6 +169,10 @@
|
|||
.config-item-icon-wrapper.api-key { background: rgba(16, 185, 129, 0.1); color: #10b981; }
|
||||
.config-item-icon-wrapper.provider-pool { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; }
|
||||
.config-item-icon-wrapper.system-prompt { background: rgba(245, 158, 11, 0.1); color: #f59e0b; }
|
||||
.config-item-icon-wrapper.plugins { background: rgba(236, 72, 153, 0.1); color: #ec4899; }
|
||||
.config-item-icon-wrapper.usage { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
|
||||
.config-item-icon-wrapper.config { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
|
||||
.config-item-icon-wrapper.database { background: rgba(16, 185, 129, 0.1); color: #10b981; }
|
||||
.config-item-icon-wrapper.other { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
|
||||
|
||||
.config-item-title-area {
|
||||
|
|
|
|||
|
|
@ -304,7 +304,8 @@
|
|||
// 跳转到主页
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
showError(data.message || t('login.error.incorrect'));
|
||||
const errorMsg = data.messageCode ? t(data.messageCode, data.messageParams) : (data.message || t('login.error.incorrect'));
|
||||
showError(errorMsg);
|
||||
loginButton.disabled = false;
|
||||
loginButton.innerHTML = t('login.button');
|
||||
passwordInput.value = '';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1010
static/potluck.html
1010
static/potluck.html
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue