diff --git a/.gitignore b/.gitignore index 190bb19..3acba33 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ CLAUDE.md config.json provider_pools.json fetch_system_prompt.txt -input_system_prompt.txt \ No newline at end of file +input_system_prompt.txt +pwd +import-accounts.ps1 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/run-docker.ps1 b/run-docker.ps1 new file mode 100644 index 0000000..4ff8a5a --- /dev/null +++ b/run-docker.ps1 @@ -0,0 +1,166 @@ +# run-docker-with-credentials.ps1 +# 生成指定的Docker运行命令,使用环境变量构建路径 + +Write-Host "正在生成指定的Docker运行命令..." -ForegroundColor Green + +# 设置配置文件路径,使用环境变量 +$AWS_SSO_CACHE_PATH = Join-Path $env:USERPROFILE ".aws\sso\cache" +$GEMINI_CONFIG_PATH = Join-Path $env:USERPROFILE ".gemini\oauth_creds.json" +$CONFIG_JSON_PATH = Join-Path $PSScriptRoot "config.json" +$CONFIG_EXAMPLE_PATH = Join-Path $PSScriptRoot "config.json.example" + +# 设置数据目录映射路径 +$DATA_DIR_PATH = "/home/2api_data" + +# 自动修补config.json +Write-Host "" +Write-Host "检查配置文件..." -ForegroundColor Green + +if (-not (Test-Path $CONFIG_EXAMPLE_PATH)) { + Write-Host "错误:未找到 config.json.example 文件!" -ForegroundColor Red + Read-Host "按Enter键退出" + exit 1 +} + +try { + $exampleConfig = Get-Content $CONFIG_EXAMPLE_PATH -Raw | ConvertFrom-Json + + if (Test-Path $CONFIG_JSON_PATH) { + Write-Host "发现现有配置文件,正在合并新字段..." -ForegroundColor Cyan + $currentConfig = Get-Content $CONFIG_JSON_PATH -Raw | ConvertFrom-Json + + # 合并配置:example中的新字段添加到current中,保留current中已有的值 + $exampleConfig.PSObject.Properties | ForEach-Object { + $key = $_.Name + if (-not ($currentConfig.PSObject.Properties.Name -contains $key)) { + Write-Host " 添加新字段: $key" -ForegroundColor Yellow + $currentConfig | Add-Member -NotePropertyName $key -NotePropertyValue $_.Value -Force + } + } + + # 保存合并后的配置 + $currentConfig | ConvertTo-Json -Depth 10 | Set-Content $CONFIG_JSON_PATH -Encoding UTF8 + Write-Host "配置文件已更新" -ForegroundColor Green + } else { + Write-Host "未找到 config.json,从 config.json.example 创建..." -ForegroundColor Yellow + Copy-Item $CONFIG_EXAMPLE_PATH $CONFIG_JSON_PATH + Write-Host "已创建 config.json,请根据需要修改配置" -ForegroundColor Green + } +} catch { + Write-Host "配置文件处理失败: $_" -ForegroundColor Red + Read-Host "按Enter键退出" + exit 1 +} + +# 检查AWS SSO缓存目录是否存在 +if (Test-Path $AWS_SSO_CACHE_PATH) { + Write-Host "发现AWS SSO缓存目录: $AWS_SSO_CACHE_PATH" -ForegroundColor Cyan +} else { + Write-Host "未找到AWS SSO缓存目录: $AWS_SSO_CACHE_PATH" -ForegroundColor Yellow + Write-Host "注意:AWS SSO缓存目录不存在,Docker容器可能无法访问AWS凭证" -ForegroundColor Yellow +} + +# 检查Gemini配置文件是否存在 +if (Test-Path $GEMINI_CONFIG_PATH) { + Write-Host "发现Gemini配置文件: $GEMINI_CONFIG_PATH" -ForegroundColor Cyan +} else { + Write-Host "未找到Gemini配置文件: $GEMINI_CONFIG_PATH" -ForegroundColor Yellow + Write-Host "注意:Gemini配置文件不存在,Docker容器可能无法访问Gemini API" -ForegroundColor Yellow +} + +# 检查并清理旧容器 +Write-Host "" +Write-Host "检查是否存在旧容器..." -ForegroundColor Green +$containerExists = docker ps -a -q -f name=aiclient2api 2>$null +if ($containerExists) { + Write-Host "发现已存在的容器 'aiclient2api',正在停止并删除..." -ForegroundColor Yellow + docker stop aiclient2api 2>$null | Out-Null + docker rm aiclient2api 2>$null | Out-Null + Write-Host "旧容器已清理" -ForegroundColor Green +} else { + Write-Host "未发现旧容器" -ForegroundColor Cyan +} + +# 检查Docker镜像是否存在 +Write-Host "" +Write-Host "检查Docker镜像..." -ForegroundColor Green +$imageExists = docker images -q aiclient2api 2>$null +if (-not $imageExists) { + Write-Host "未找到Docker镜像 'aiclient2api',开始构建..." -ForegroundColor Yellow + Write-Host "执行: docker build -t aiclient2api ." -ForegroundColor Cyan + docker build -t aiclient2api . + if ($LASTEXITCODE -ne 0) { + Write-Host "Docker镜像构建失败!" -ForegroundColor Red + Read-Host "按Enter键退出" + exit 1 + } + Write-Host "Docker镜像构建成功!" -ForegroundColor Green +} else { + Write-Host "发现Docker镜像 'aiclient2api'" -ForegroundColor Cyan +} + +# 构建Docker运行命令 +# 注意:挂载config.json后,容器会优先使用配置文件中的设置 +# 命令行参数(ARGS)会覆盖config.json中的对应配置 +# 确保数据目录存在 +if (-not (Test-Path $DATA_DIR_PATH)) { + Write-Host "创建数据目录: $DATA_DIR_PATH" -ForegroundColor Yellow + New-Item -ItemType Directory -Path $DATA_DIR_PATH -Force | Out-Null +} + +$volumeMounts = @( + "-v `"${AWS_SSO_CACHE_PATH}:/root/.aws/sso/cache`"" + "-v `"${GEMINI_CONFIG_PATH}:/root/.gemini/oauth_creds.json`"" + "-v `"${DATA_DIR_PATH}:/home/2api_data`"" +) + +# 如果config.json存在,挂载到容器中 +if (Test-Path $CONFIG_JSON_PATH) { + $volumeMounts += "-v `"${CONFIG_JSON_PATH}:/app/config.json`"" + Write-Host "将挂载config.json到容器(可读写)" -ForegroundColor Cyan +} + +Write-Host "将挂载数据目录: $DATA_DIR_PATH -> /home/2api_data" -ForegroundColor Cyan + +$DOCKER_CMD = @( + "docker run -d" + "--restart=always" + "--privileged=true" + "-p 3000:3000" + $volumeMounts -join " " + "--name aiclient2api" + "aiclient2api" +) -join " " + +# 显示将要执行的命令 +Write-Host "" +Write-Host "生成的Docker命令:" -ForegroundColor Green +Write-Host $DOCKER_CMD -ForegroundColor White +Write-Host "" + +# 将命令保存到文件中 +$DOCKER_CMD | Out-File -FilePath "docker-run-command.txt" -Encoding UTF8 +Write-Host "命令已保存到 docker-run-command.txt 文件中,您可以从该文件复制完整的命令。" -ForegroundColor Green + +# 询问用户是否要执行该命令 +Write-Host "" +$EXECUTE_CMD = Read-Host "是否要立即执行该Docker命令?(y/n)" +if ($EXECUTE_CMD -eq "y" -or $EXECUTE_CMD -eq "Y") { + Write-Host "正在执行Docker命令..." -ForegroundColor Green + try { + Invoke-Expression $DOCKER_CMD + if ($LASTEXITCODE -eq 0) { + Write-Host "Docker容器已成功启动!" -ForegroundColor Green + Write-Host "您可以通过 http://localhost:3000 访问API服务" -ForegroundColor Cyan + } else { + Write-Host "Docker命令执行失败,请检查错误信息" -ForegroundColor Red + } + } catch { + Write-Host "Docker命令执行失败: $_" -ForegroundColor Red + } +} else { + Write-Host "命令未执行,您可以手动从docker-run-command.txt文件复制并执行命令" -ForegroundColor Yellow +} + +Write-Host "脚本执行完成" -ForegroundColor Green +Read-Host "按Enter键退出" diff --git a/run-docker.sh b/run-docker.sh index c0a0b49..6684698 100644 --- a/run-docker.sh +++ b/run-docker.sh @@ -1,65 +1,281 @@ #!/bin/bash -# run-docker-with-credentials.sh -# 生成指定的Docker运行命令,使用HOME环境变量构建路径 +# run-docker.sh +# 本地构建、打包并运行 Docker 容器 -echo "正在生成指定的Docker运行命令..." +set -e -# 设置配置文件路径,使用HOME环境变量 -AWS_SSO_CACHE_PATH="$HOME/.aws/sso/cache" -GEMINI_CONFIG_PATH="$HOME/.gemini/oauth_creds.json" +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' -# 检查AWS SSO缓存目录是否存在 -if [ -d "$AWS_SSO_CACHE_PATH" ]; then - echo "发现AWS SSO缓存目录: $AWS_SSO_CACHE_PATH" -else - echo "未找到AWS SSO缓存目录: $AWS_SSO_CACHE_PATH" - echo "注意:AWS SSO缓存目录不存在,Docker容器可能无法访问AWS凭证" +# 日志函数 +log_info() { echo -e "${GREEN}$1${NC}"; } +log_warn() { echo -e "${YELLOW}$1${NC}"; } +log_error() { echo -e "${RED}$1${NC}"; } +log_cyan() { echo -e "${CYAN}$1${NC}"; } + +error_exit() { + log_error "错误: $1" + exit 1 +} + +# 获取脚本目录 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" || error_exit "无法切换到脚本目录" + +log_info "正在准备 Docker 环境..." +echo "" + +# ========== 前置检查 ========== +log_info "检查前置条件..." + +if ! command -v docker &> /dev/null; then + error_exit "未找到 Docker,请先安装 Docker" fi -# 检查Gemini配置文件是否存在 -if [ -f "$GEMINI_CONFIG_PATH" ]; then - echo "发现Gemini配置文件: $GEMINI_CONFIG_PATH" -else - echo "未找到Gemini配置文件: $GEMINI_CONFIG_PATH" - echo "注意:Gemini配置文件不存在,Docker容器可能无法访问Gemini API" +if ! docker info &> /dev/null; then + error_exit "Docker 未运行,请启动 Docker 服务" fi -# 构建Docker运行命令,使用HOME环境变量构建的路径 -DOCKER_CMD="docker run -d \\ - -u "$(id -u):$(id -g)" \\ - --restart=always \\ - --privileged=true \\ - -p 3000:3000 \\ - -e ARGS=\"--api-key 123456 --host 0.0.0.0\" \\ - -v $AWS_SSO_CACHE_PATH:/root/.aws/sso/cache \\ - -v $GEMINI_CONFIG_PATH:/root/.gemini/oauth_creds.json \\ - --name aiclient2api \\ - aiclient2api" +log_cyan "Docker 环境正常" +echo "" -# 显示将要执行的命令 -echo -echo "生成的Docker命令:" -echo "$DOCKER_CMD" -echo +# ========== 配置文件处理 ========== +log_info "检查配置文件..." -# 将命令保存到文件中 -echo "$DOCKER_CMD" > docker-run-command.txt -echo "命令已保存到 docker-run-command.txt 文件中,您可以从该文件复制完整的命令。" +DATA_DIR_PATH="/home/2api_data" +CONFIG_JSON_PATH="$DATA_DIR_PATH/config.json" +CONFIG_EXAMPLE_PATH="$SCRIPT_DIR/config.json.example" -# 询问用户是否要执行该命令 -echo -read -p "是否要立即执行该Docker命令?(y/n): " EXECUTE_CMD -if [ "$EXECUTE_CMD" = "y" ] || [ "$EXECUTE_CMD" = "Y" ]; then - echo "正在执行Docker命令..." - eval "$DOCKER_CMD" - if [ $? -eq 0 ]; then - echo "Docker容器已成功启动!" - echo "您可以通过 http://localhost:3000 访问API服务" +# 确保数据目录存在 +if [ ! -d "$DATA_DIR_PATH" ]; then + log_warn "正在创建数据目录: $DATA_DIR_PATH" + mkdir -p "$DATA_DIR_PATH" || error_exit "创建数据目录失败" +fi + +if [ ! -f "$CONFIG_EXAMPLE_PATH" ]; then + error_exit "未找到 config.json.example 文件!" +fi + +if command -v jq &> /dev/null; then + HAS_JQ=true +else + HAS_JQ=false + log_warn "未安装 jq,使用简单配置处理" +fi + +if [ -f "$CONFIG_JSON_PATH" ]; then + log_cyan "发现现有配置文件" + if [ "$HAS_JQ" = true ]; then + log_cyan "正在合并新字段..." + MERGED=$(jq -s '.[0] * .[1] | .[1] * .' "$CONFIG_EXAMPLE_PATH" "$CONFIG_JSON_PATH" 2>/dev/null) || { + log_warn "配置合并失败,保留现有配置" + MERGED="" + } + if [ -n "$MERGED" ]; then + echo "$MERGED" > "$CONFIG_JSON_PATH" + log_info "配置文件已更新" + fi else - echo "Docker命令执行失败,请检查错误信息" + log_cyan "保留现有配置文件" fi else - echo "命令未执行,您可以手动从docker-run-command.txt文件复制并执行命令" + log_warn "未找到 config.json,从 config.json.example 创建..." + cp "$CONFIG_EXAMPLE_PATH" "$CONFIG_JSON_PATH" || error_exit "无法创建配置文件" + log_info "已创建 config.json,请根据需要修改配置" +fi +echo "" + +# ========== 凭证路径 ========== +AWS_SSO_CACHE_PATH="$HOME/.aws/sso/cache" +GEMINI_CONFIG_PATH="$HOME/.gemini/oauth_creds.json" +CONFIGS_DIR_PATH="$DATA_DIR_PATH/configs" +LOGS_DIR_PATH="$DATA_DIR_PATH/logs" + +if [ -d "$AWS_SSO_CACHE_PATH" ]; then + log_cyan "发现 AWS SSO 缓存: $AWS_SSO_CACHE_PATH" + AWS_MOUNT="-v $AWS_SSO_CACHE_PATH:/root/.aws/sso/cache" +else + log_warn "未找到 AWS SSO 缓存: $AWS_SSO_CACHE_PATH" + log_warn "注意: Docker 容器可能无法访问 AWS 凭证" + AWS_MOUNT="" fi -echo "脚本执行完成" \ No newline at end of file +if [ -f "$GEMINI_CONFIG_PATH" ]; then + log_cyan "发现 Gemini 配置: $GEMINI_CONFIG_PATH" + GEMINI_MOUNT="-v $GEMINI_CONFIG_PATH:/root/.gemini/oauth_creds.json" +else + log_warn "未找到 Gemini 配置: $GEMINI_CONFIG_PATH" + log_warn "注意: Docker 容器可能无法访问 Gemini API" + GEMINI_MOUNT="" +fi + +# 数据目录已在上面创建 +log_cyan "发现数据目录: $DATA_DIR_PATH" + +# 确保 configs 目录存在 +if [ ! -d "$CONFIGS_DIR_PATH" ]; then + log_warn "正在创建 configs 目录: $CONFIGS_DIR_PATH" + mkdir -p "$CONFIGS_DIR_PATH" || log_warn "创建 configs 目录失败" +fi + +if [ -d "$CONFIGS_DIR_PATH" ]; then + log_cyan "发现 configs 目录: $CONFIGS_DIR_PATH" + CONFIGS_MOUNT="-v $CONFIGS_DIR_PATH:/app/configs" +else + log_warn "未找到 configs 目录: $CONFIGS_DIR_PATH" + CONFIGS_MOUNT="" +fi + +# 确保 logs 目录存在 +if [ ! -d "$LOGS_DIR_PATH" ]; then + log_warn "正在创建 logs 目录: $LOGS_DIR_PATH" + mkdir -p "$LOGS_DIR_PATH" || log_warn "创建 logs 目录失败" +fi + +if [ -d "$LOGS_DIR_PATH" ]; then + log_cyan "发现 logs 目录: $LOGS_DIR_PATH" + LOGS_MOUNT="-v $LOGS_DIR_PATH:/app/logs" +else + log_warn "未找到 logs 目录: $LOGS_DIR_PATH" + LOGS_MOUNT="" +fi + +# provider_pools.json 文件路径 +PROVIDER_POOLS_PATH="$DATA_DIR_PATH/provider_pools.json" +if [ ! -f "$PROVIDER_POOLS_PATH" ]; then + log_warn "正在创建 provider_pools.json: $PROVIDER_POOLS_PATH" + echo "{}" > "$PROVIDER_POOLS_PATH" || log_warn "创建 provider_pools.json 失败" +fi + +if [ -f "$PROVIDER_POOLS_PATH" ]; then + log_cyan "发现 provider_pools.json: $PROVIDER_POOLS_PATH" + PROVIDER_POOLS_MOUNT="-v $PROVIDER_POOLS_PATH:/app/provider_pools.json" +else + log_warn "未找到 provider_pools.json: $PROVIDER_POOLS_PATH" + PROVIDER_POOLS_MOUNT="" +fi +echo "" + +# ========== 清理旧容器 ========== +log_info "检查是否存在旧容器..." + +CONTAINER_ID=$(docker ps -a -q -f name=aiclient2api 2>/dev/null) +if [ -n "$CONTAINER_ID" ]; then + log_warn "发现已存在的容器 'aiclient2api',正在停止并删除..." + docker stop aiclient2api 2>/dev/null || true + docker rm aiclient2api 2>/dev/null || true + log_info "旧容器已清理" +else + log_cyan "未发现旧容器" +fi +echo "" + +# ========== 构建 Docker 镜像 ========== +log_info "检查 Docker 镜像..." + +FORCE_BUILD=false +if docker images -q aiclient2api 2>/dev/null | grep -q .; then + log_cyan "发现已存在的 Docker 镜像 'aiclient2api'" + read -p "是否强制重新构建镜像?(y/n, 默认 n): " REBUILD_CHOICE + if [ "$REBUILD_CHOICE" = "y" ] || [ "$REBUILD_CHOICE" = "Y" ]; then + FORCE_BUILD=true + log_warn "将删除旧镜像并重新构建..." + docker rmi aiclient2api 2>/dev/null || log_warn "删除旧镜像失败,继续执行..." + fi +else + FORCE_BUILD=true +fi + +if [ "$FORCE_BUILD" = true ]; then + log_info "开始本地构建 Docker 镜像..." + log_cyan "执行: docker build -t aiclient2api ." + echo "" + + if ! docker build -t aiclient2api . ; then + error_exit "Docker 镜像构建失败!请检查 Dockerfile 和源代码" + fi + + log_info "Docker 镜像构建成功!" +fi +echo "" + +# ========== 构建运行命令 ========== +log_info "准备 Docker 运行命令..." + +VOLUME_MOUNTS="" +[ -n "$AWS_MOUNT" ] && VOLUME_MOUNTS="$VOLUME_MOUNTS $AWS_MOUNT" +[ -n "$GEMINI_MOUNT" ] && VOLUME_MOUNTS="$VOLUME_MOUNTS $GEMINI_MOUNT" +[ -n "$CONFIGS_MOUNT" ] && VOLUME_MOUNTS="$VOLUME_MOUNTS $CONFIGS_MOUNT" +[ -n "$LOGS_MOUNT" ] && VOLUME_MOUNTS="$VOLUME_MOUNTS $LOGS_MOUNT" +[ -n "$PROVIDER_POOLS_MOUNT" ] && VOLUME_MOUNTS="$VOLUME_MOUNTS $PROVIDER_POOLS_MOUNT" + +if [ -f "$CONFIG_JSON_PATH" ]; then + VOLUME_MOUNTS="$VOLUME_MOUNTS -v $CONFIG_JSON_PATH:/app/config.json" + log_cyan "将挂载 config.json: $CONFIG_JSON_PATH -> /app/config.json" +fi + +if [ -n "$CONFIGS_MOUNT" ]; then + log_cyan "将挂载 configs: $CONFIGS_DIR_PATH -> /app/configs" +fi + +if [ -n "$LOGS_MOUNT" ]; then + log_cyan "将挂载 logs: $LOGS_DIR_PATH -> /app/logs" +fi + +if [ -n "$PROVIDER_POOLS_MOUNT" ]; then + log_cyan "将挂载 provider_pools.json: $PROVIDER_POOLS_PATH -> /app/provider_pools.json" +fi + +DOCKER_CMD="docker run -d \ + --restart=always \ + --privileged=true \ + -p 3000:3000 \ + $VOLUME_MOUNTS \ + --name aiclient2api \ + aiclient2api" + +echo "" +log_info "生成的 Docker 命令:" +echo "$DOCKER_CMD" +echo "" + +echo "$DOCKER_CMD" > docker-run-command.txt +log_info "命令已保存到 docker-run-command.txt" +echo "" + +# ========== 运行容器 ========== +read -p "是否立即执行 Docker 命令?(y/n, 默认 y): " EXECUTE_CMD +EXECUTE_CMD=${EXECUTE_CMD:-y} + +if [ "$EXECUTE_CMD" = "y" ] || [ "$EXECUTE_CMD" = "Y" ]; then + log_info "正在启动 Docker 容器..." + + if eval "$DOCKER_CMD"; then + echo "" + log_info "Docker 容器启动成功!" + log_cyan "访问 API 服务: http://localhost:3000" + echo "" + + log_info "容器状态:" + docker ps -f name=aiclient2api --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + + sleep 3 + if docker ps -q -f name=aiclient2api -f status=running | grep -q .; then + log_info "容器运行正常" + else + log_warn "容器可能启动失败,正在检查日志:" + docker logs aiclient2api 2>&1 | tail -20 + fi + else + error_exit "Docker 容器启动失败!" + fi +else + log_warn "命令未执行,您可以从 docker-run-command.txt 复制命令手动执行" +fi + +echo "" +log_info "脚本执行完成" diff --git a/src/converters/strategies/OpenAIResponsesConverter.js b/src/converters/strategies/OpenAIResponsesConverter.js index dfd7e06..1c635d8 100644 --- a/src/converters/strategies/OpenAIResponsesConverter.js +++ b/src/converters/strategies/OpenAIResponsesConverter.js @@ -257,6 +257,10 @@ export class OpenAIResponsesConverter extends BaseConverter { // 如果有标准的 messages 字段,也支持 if (responsesRequest.messages && Array.isArray(responsesRequest.messages)) { + const { systemMessages, otherMessages } = extractSystemMessages( + responsesRequest.messages + ); + if (!claudeRequest.system && systemMessages.length > 0) { const systemTexts = systemMessages.map(msg => extractText(msg.content)); claudeRequest.system = systemTexts.join('\n'); diff --git a/src/provider-models.js b/src/provider-models.js index f2de251..c629405 100644 --- a/src/provider-models.js +++ b/src/provider-models.js @@ -25,11 +25,11 @@ export const PROVIDER_MODELS = { 'claude-opus-4-5', 'claude-haiku-4-5', 'claude-sonnet-4-5', - 'claude-sonnet-4-5-20250929', + // 'claude-sonnet-4-5-20250929', 'claude-sonnet-4-20250514', - 'claude-3-7-sonnet-20250219', - 'amazonq-claude-sonnet-4-20250514', - 'amazonq-claude-3-7-sonnet-20250219' + // 'claude-3-7-sonnet-20250219', + // 'amazonq-claude-sonnet-4-20250514', + // 'amazonq-claude-3-7-sonnet-20250219' ], 'openai-custom': [], 'openaiResponses-custom': [], diff --git a/src/provider-pool-manager.js b/src/provider-pool-manager.js index 1a2ad0a..f073c74 100644 --- a/src/provider-pool-manager.js +++ b/src/provider-pool-manager.js @@ -1,6 +1,7 @@ import * as fs from 'fs'; // Import fs module import { getServiceAdapter } from './adapter.js'; import { MODEL_PROVIDER } from './common.js'; +import axios from 'axios'; /** * Manages a pool of API service providers, handling their health and selection. @@ -11,7 +12,8 @@ export class ProviderPoolManager { 'gemini-cli': 'gemini-2.5-flash', 'openai-custom': 'gpt-3.5-turbo', 'claude-custom': 'claude-3-7-sonnet-20250219', - 'kiro-api': 'claude-3-7-sonnet-20250219', + 'kiro-api': 'claude-haiku-4-5', + 'claude-kiro-oauth': 'claude-haiku-4-5', 'qwen-api': 'qwen3-coder-flash', 'openai-custom-responses': 'gpt-5-low' }; @@ -81,6 +83,11 @@ export class ProviderPoolManager { providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date ? providerConfig.lastErrorTime.toISOString() : (providerConfig.lastErrorTime || null); + + // 健康检测相关字段 + providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null; + providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null; + providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null; this.providerStatus[providerType].push({ config: providerConfig, @@ -165,8 +172,9 @@ export class ProviderPoolManager { * Marks a provider as unhealthy (e.g., after an API error). * @param {string} providerType - The type of the provider. * @param {object} providerConfig - The configuration of the provider to mark. + * @param {string} [errorMessage] - Optional error message to store. */ - markProviderUnhealthy(providerType, providerConfig) { + markProviderUnhealthy(providerType, providerConfig, errorMessage = null) { if (!providerConfig?.uuid) { this._log('error', 'Invalid providerConfig in markProviderUnhealthy'); return; @@ -176,6 +184,11 @@ export class ProviderPoolManager { if (provider) { provider.config.errorCount++; provider.config.lastErrorTime = new Date().toISOString(); + + // 保存错误信息 + if (errorMessage) { + provider.config.lastErrorMessage = errorMessage; + } if (provider.config.errorCount >= this.maxErrorCount) { provider.config.isHealthy = false; @@ -192,9 +205,10 @@ export class ProviderPoolManager { * Marks a provider as healthy. * @param {string} providerType - The type of the provider. * @param {object} providerConfig - The configuration of the provider to mark. - * @param {boolean} isInit - Whether to reset usage count (optional, default: false). + * @param {boolean} resetUsageCount - Whether to reset usage count (optional, default: false). + * @param {string} [healthCheckModel] - Optional model name used for health check. */ - markProviderHealthy(providerType, providerConfig, resetUsageCount = false) { + markProviderHealthy(providerType, providerConfig, resetUsageCount = false, healthCheckModel = null) { if (!providerConfig?.uuid) { this._log('error', 'Invalid providerConfig in markProviderHealthy'); return; @@ -205,6 +219,14 @@ export class ProviderPoolManager { provider.config.isHealthy = true; provider.config.errorCount = 0; provider.config.lastErrorTime = null; + provider.config.lastErrorMessage = null; + + // 更新健康检测信息 + provider.config.lastHealthCheckTime = new Date().toISOString(); + if (healthCheckModel) { + provider.config.lastHealthCheckModel = healthCheckModel; + } + // 只有在明确要求重置使用计数时才重置 if (resetUsageCount) { provider.config.usageCount = 0; @@ -295,121 +317,224 @@ export class ProviderPoolManager { try { // Perform actual health check based on provider type - const isHealthy = await this._checkProviderHealth(providerType, providerConfig); + const healthResult = await this._checkProviderHealth(providerType, providerConfig); - if (isHealthy === null) { + if (healthResult === null) { this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}) skipped: Check not implemented.`); this.resetProviderCounters(providerType, providerConfig); continue; } - if (isHealthy) { + if (healthResult.success) { if (!providerStatus.config.isHealthy) { // Provider was unhealthy but is now healthy // 恢复健康时不重置使用计数,保持原有值 - this.markProviderHealthy(providerType, providerConfig, true); + this.markProviderHealthy(providerType, providerConfig, true, healthResult.modelName); this._log('info', `Health check for ${providerConfig.uuid} (${providerType}): Marked Healthy (actual check)`); } else { // Provider was already healthy and still is // 只在初始化时重置使用计数 - this.markProviderHealthy(providerType, providerConfig, true); + this.markProviderHealthy(providerType, providerConfig, true, healthResult.modelName); this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}): Still Healthy`); } } else { // Provider is not healthy - this._log('warn', `Health check for ${providerConfig.uuid} (${providerType}) failed: Provider is not responding correctly.`); - this.markProviderUnhealthy(providerType, providerConfig); + this._log('warn', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${healthResult.errorMessage || 'Provider is not responding correctly.'}`); + this.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage); + + // 更新健康检测时间和模型(即使失败也记录) + providerStatus.config.lastHealthCheckTime = new Date().toISOString(); + if (healthResult.modelName) { + providerStatus.config.lastHealthCheckModel = healthResult.modelName; + } } } catch (error) { this._log('error', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${error.message}`); // If a health check fails, mark it unhealthy, which will update error count and lastErrorTime - this.markProviderUnhealthy(providerType, providerConfig); + this.markProviderUnhealthy(providerType, providerConfig, error.message); } } } } /** - * 构建健康检查请求 + * 构建健康检查请求(返回多种格式用于重试) * @private + * @returns {Array} 请求格式数组,按优先级排序 */ - _buildHealthCheckRequest(providerType, modelName) { - const baseMessage = { role: 'user', content: 'Hello, are you ok?' }; + _buildHealthCheckRequests(providerType, modelName) { + const baseMessage = { role: 'user', content: 'Hi' }; + const requests = []; - // Gemini 使用不同的请求格式 + // Gemini 使用 contents 格式 if (providerType.startsWith('gemini')) { - return { + requests.push({ contents: [{ role: 'user', parts: [{ text: baseMessage.content }] }] - }; + }); + return requests; + } + + // Kiro OAuth 同时支持 messages 和 contents 格式 + if (providerType.startsWith('claude-kiro')) { + // 优先使用 messages 格式 + requests.push({ + messages: [baseMessage], + model: modelName, + max_tokens: 1 + }); + // 备用 contents 格式 + requests.push({ + contents: [{ + role: 'user', + parts: [{ text: baseMessage.content }] + }], + max_tokens: 1 + }); + return requests; } // OpenAI Custom Responses 使用特殊格式 if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) { - return { + requests.push({ input: [baseMessage], model: modelName - }; + }); + return requests; } - // 其他提供商(OpenAI、Claude、Kiro、Qwen)使用标准格式 - return { + // 其他提供商(OpenAI、Claude、Qwen)使用标准 messages 格式 + requests.push({ messages: [baseMessage], model: modelName - }; + }); + + return requests; } /** * Performs an actual health check for a specific provider. * @param {string} providerType - The type of the provider. * @param {object} providerConfig - The configuration of the provider to check. - * @returns {Promise} - True if healthy, false if unhealthy, null if check not implemented. + * @returns {Promise<{success: boolean, modelName: string, errorMessage: string}|null>} - Health check result object or null if check not implemented. */ async _checkProviderHealth(providerType, providerConfig) { - try { - // 如果未启用健康检查,返回 null - if (!providerConfig.checkHealth) { - return null; - } + // 确定健康检查使用的模型名称 + const modelName = providerConfig.checkModelName || + ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]; + + // 如果未启用健康检查,返回 null + if (!providerConfig.checkHealth) { + return null; + } - // 合并全局配置和 provider 配置(简化代理配置) - const proxyKeys = ['GEMINI', 'OPENAI', 'CLAUDE', 'QWEN', 'KIRO']; - const tempConfig = { - ...providerConfig, - MODEL_PROVIDER: providerType - }; - - // 动态添加代理配置 - proxyKeys.forEach(key => { - const proxyKey = `USE_SYSTEM_PROXY_${key}`; - if (this.globalConfig[proxyKey] !== undefined) { - tempConfig[proxyKey] = this.globalConfig[proxyKey]; - } + if (!modelName) { + this._log('warn', `Unknown provider type for health check: ${providerType}`); + return { success: false, modelName: null, errorMessage: 'Unknown provider type for health check' }; + } + + // 使用内部服务适配器方式进行健康检查 + const proxyKeys = ['GEMINI', 'OPENAI', 'CLAUDE', 'QWEN', 'KIRO']; + const tempConfig = { + ...providerConfig, + MODEL_PROVIDER: providerType + }; + + proxyKeys.forEach(key => { + const proxyKey = `USE_SYSTEM_PROXY_${key}`; + if (this.globalConfig[proxyKey] !== undefined) { + tempConfig[proxyKey] = this.globalConfig[proxyKey]; + } + }); + + const serviceAdapter = getServiceAdapter(tempConfig); + + // 获取所有可能的请求格式 + const healthCheckRequests = this._buildHealthCheckRequests(providerType, modelName); + + // 重试机制:尝试不同的请求格式 + const maxRetries = healthCheckRequests.length; + let lastError = null; + + for (let i = 0; i < maxRetries; i++) { + const healthCheckRequest = healthCheckRequests[i]; + try { + this._log('debug', `Health check attempt ${i + 1}/${maxRetries} for ${modelName}: ${JSON.stringify(healthCheckRequest)}`); + await serviceAdapter.generateContent(modelName, healthCheckRequest); + return { success: true, modelName, errorMessage: null }; + } catch (error) { + lastError = error; + this._log('debug', `Health check attempt ${i + 1} failed for ${providerType}: ${error.message}`); + // 继续尝试下一个格式 + } + } + + // 所有尝试都失败 + this._log('error', `Health check failed for ${providerType} after ${maxRetries} attempts: ${lastError?.message}`); + return { success: false, modelName, errorMessage: lastError?.message || 'All health check attempts failed' }; + } + + /** + * 通过 HTTP API 进行健康检查(更通用、更节省 tokens) + * @private + */ + async _httpHealthCheck(providerType, providerConfig, modelName) { + // 确定 API URL 和 Key + let baseUrl = null; + let apiKey = null; + + // 根据 provider 类型获取配置 + if (providerType.includes('kiro') || providerType.includes('claude-kiro')) { + baseUrl = providerConfig.KIRO_BASE_URL; + apiKey = providerConfig.KIRO_API_KEY; + } else if (providerType.includes('openai')) { + baseUrl = providerConfig.OPENAI_BASE_URL; + apiKey = providerConfig.OPENAI_API_KEY; + } else if (providerType.includes('claude')) { + baseUrl = providerConfig.CLAUDE_BASE_URL; + apiKey = providerConfig.CLAUDE_API_KEY; + } else if (providerType.includes('qwen')) { + baseUrl = providerConfig.QWEN_BASE_URL; + apiKey = providerConfig.QWEN_API_KEY; + } + + // 如果没有配置 HTTP API,返回 null 让调用方回退到其他方式 + if (!baseUrl || !apiKey) { + return null; + } + + // 确保 URL 格式正确 + const url = baseUrl.endsWith('/') + ? `${baseUrl}chat/completions` + : `${baseUrl}/chat/completions`; + + this._log('debug', `HTTP health check for ${providerType}: ${url}`); + + try { + const response = await axios.post(url, { + model: modelName, + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 1 // 最节省 tokens + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + timeout: 30000 // 30秒超时 }); - const serviceAdapter = getServiceAdapter(tempConfig); - - // 确定健康检查使用的模型名称 - const modelName = providerConfig.checkModelName || - ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]; - - if (!modelName) { - this._log('warn', `Unknown provider type for health check: ${providerType}`); - return false; + if (response.status === 200) { + return { success: true, modelName, errorMessage: null }; + } else { + return { success: false, modelName, errorMessage: `HTTP ${response.status}` }; } - - // 构建健康检查请求 - const healthCheckRequest = this._buildHealthCheckRequest(providerType, modelName); - - this._log('debug', `Health check request for ${modelName}: ${JSON.stringify(healthCheckRequest)}`); - await serviceAdapter.generateContent(modelName, healthCheckRequest); - return true; } catch (error) { - this._log('error', `Health check failed for ${providerType}: ${error.message}`); - return false; + const errorMsg = error.response?.data?.error?.message || error.message; + this._log('error', `HTTP health check failed: ${errorMsg}`); + return { success: false, modelName, errorMessage: errorMsg }; } } @@ -472,6 +597,9 @@ export class ProviderPoolManager { if (config.lastErrorTime instanceof Date) { config.lastErrorTime = config.lastErrorTime.toISOString(); } + if (config.lastHealthCheckTime instanceof Date) { + config.lastHealthCheckTime = config.lastHealthCheckTime.toISOString(); + } return config; }); } else { diff --git a/src/service-manager.js b/src/service-manager.js index 123cefc..cb5e414 100644 --- a/src/service-manager.js +++ b/src/service-manager.js @@ -1,16 +1,194 @@ import { getServiceAdapter, serviceInstances } from './adapter.js'; import { ProviderPoolManager } from './provider-pool-manager.js'; import deepmerge from 'deepmerge'; +import * as fs from 'fs'; +import { promises as pfs } from 'fs'; +import * as path from 'path'; // 存储 ProviderPoolManager 实例 let providerPoolManager = null; +/** + * 生成 UUID + * @returns {string} UUID 字符串 + */ +function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +/** + * 扫描 configs/kiro 目录并自动关联未关联的配置文件到 claude-kiro-oauth 提供商 + * @param {Object} config - 服务器配置对象 + * @returns {Promise} 更新后的 providerPools 对象 + */ +async function autoLinkKiroConfigs(config) { + const kiroConfigsPath = path.join(process.cwd(), 'configs', 'kiro'); + const providerType = 'claude-kiro-oauth'; + const defaultCheckModel = 'claude-haiku-4-5'; + + // 确保 providerPools 对象存在 + if (!config.providerPools) { + config.providerPools = {}; + } + + // 确保 claude-kiro-oauth 数组存在 + if (!config.providerPools[providerType]) { + config.providerPools[providerType] = []; + } + + // 检查 configs/kiro 目录是否存在 + if (!fs.existsSync(kiroConfigsPath)) { + console.log('[Auto-Link] configs/kiro directory not found, skipping auto-link'); + return config.providerPools; + } + + // 获取已关联的配置文件路径集合 + const linkedPaths = new Set(); + for (const provider of config.providerPools[providerType]) { + if (provider.KIRO_OAUTH_CREDS_FILE_PATH) { + // 标准化路径以便比较 + const normalizedPath = provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); + linkedPaths.add(normalizedPath); + linkedPaths.add(normalizedPath.replace(/^\.\//, '')); + if (!normalizedPath.startsWith('./')) { + linkedPaths.add('./' + normalizedPath); + } + } + } + + // 递归扫描 configs/kiro 目录 + const newProviders = []; + await scanKiroDirectory(kiroConfigsPath, linkedPaths, newProviders, defaultCheckModel); + + // 如果有新的配置文件需要关联 + if (newProviders.length > 0) { + config.providerPools[providerType].push(...newProviders); + + // 保存更新后的 provider_pools.json + const filePath = config.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + try { + await pfs.writeFile(filePath, JSON.stringify(config.providerPools, null, 2), 'utf8'); + console.log(`[Auto-Link] Added ${newProviders.length} new Kiro config(s) to ${providerType}`); + newProviders.forEach(p => { + console.log(` - ${p.KIRO_OAUTH_CREDS_FILE_PATH}`); + }); + } catch (error) { + console.error(`[Auto-Link] Failed to save provider_pools.json: ${error.message}`); + } + } else { + console.log('[Auto-Link] No new Kiro configs to link'); + } + + return config.providerPools; +} + +/** + * 递归扫描 Kiro 配置目录 + * @param {string} dirPath - 目录路径 + * @param {Set} linkedPaths - 已关联的路径集合 + * @param {Array} newProviders - 新提供商配置数组 + * @param {string} defaultCheckModel - 默认检测模型 + */ +async function scanKiroDirectory(dirPath, linkedPaths, newProviders, defaultCheckModel) { + try { + const files = await pfs.readdir(dirPath, { withFileTypes: true }); + + for (const file of files) { + const fullPath = path.join(dirPath, file.name); + + if (file.isFile()) { + const ext = path.extname(file.name).toLowerCase(); + // 只处理 JSON 文件 + if (ext === '.json') { + const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/'); + + // 检查是否已关联 + const isLinked = linkedPaths.has(relativePath) || + linkedPaths.has('./' + relativePath) || + linkedPaths.has(relativePath.replace(/^\.\//, '')); + + if (!isLinked) { + // 验证是否是有效的 OAuth 凭据文件 + const isValid = await isValidKiroCredentials(fullPath); + if (isValid) { + // 创建新的提供商配置 + const newProvider = { + KIRO_OAUTH_CREDS_FILE_PATH: './' + relativePath, + uuid: generateUUID(), + checkModelName: defaultCheckModel, + checkHealth: true, + isHealthy: true, + isDisabled: false, + lastUsed: null, + usageCount: 0, + errorCount: 0, + lastErrorTime: null, + lastHealthCheckTime: null, + lastHealthCheckModel: null, + lastErrorMessage: null + }; + newProviders.push(newProvider); + } + } + } + } else if (file.isDirectory()) { + // 递归扫描子目录(限制深度为 3 层) + const relativePath = path.relative(process.cwd(), fullPath); + const depth = relativePath.split(path.sep).length; + if (depth < 5) { // configs/kiro/subfolder/subsubfolder + await scanKiroDirectory(fullPath, linkedPaths, newProviders, defaultCheckModel); + } + } + } + } catch (error) { + console.warn(`[Auto-Link] Failed to scan directory ${dirPath}: ${error.message}`); + } +} + +/** + * 验证文件是否是有效的 Kiro OAuth 凭据文件 + * @param {string} filePath - 文件路径 + * @returns {Promise} 是否有效 + */ +async function isValidKiroCredentials(filePath) { + try { + const content = await pfs.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(content); + + // 检查是否包含 OAuth 相关字段 + // Kiro 凭据通常包含 access_token, refresh_token, client_id 等字段 + if (jsonData.access_token || jsonData.refresh_token || + jsonData.client_id || jsonData.client_secret || + jsonData.token || jsonData.credentials) { + return true; + } + + // 也可能是包含嵌套结构的凭据文件 + if (jsonData.installed || jsonData.web) { + return true; + } + + return false; + } catch (error) { + // 如果无法解析,认为不是有效的凭据文件 + return false; + } +} + /** * Initialize API services and provider pool manager * @param {Object} config - The server configuration * @returns {Promise} The initialized services */ export async function initApiService(config) { + // 自动关联 configs/kiro 目录中的配置文件 + console.log('[Initialization] Checking for unlinked Kiro configs...'); + await autoLinkKiroConfigs(config); + if (config.providerPools && Object.keys(config.providerPools).length > 0) { providerPoolManager = new ProviderPoolManager(config.providerPools, { globalConfig: config, diff --git a/src/ui-manager.js b/src/ui-manager.js index e680593..7e12133 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -1091,6 +1091,117 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Perform health check for all providers of a specific type + const healthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/health-check$/); + if (method === 'POST' && healthCheckMatch) { + const providerType = decodeURIComponent(healthCheckMatch[1]); + + try { + if (!providerPoolManager) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } })); + return true; + } + + const providers = providerPoolManager.providerStatus[providerType] || []; + + if (providers.length === 0) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); + return true; + } + + console.log(`[UI API] Starting health check for ${providers.length} providers in ${providerType}`); + + // 执行健康检测 + const results = []; + for (const providerStatus of providers) { + const providerConfig = providerStatus.config; + try { + const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig); + + if (healthResult === null) { + results.push({ + uuid: providerConfig.uuid, + success: null, + message: '未启用健康检测 (checkHealth=false)' + }); + continue; + } + + if (healthResult.success) { + providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName); + results.push({ + uuid: providerConfig.uuid, + success: true, + modelName: healthResult.modelName, + message: '健康' + }); + } else { + providerPoolManager.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage); + providerStatus.config.lastHealthCheckTime = new Date().toISOString(); + if (healthResult.modelName) { + providerStatus.config.lastHealthCheckModel = healthResult.modelName; + } + results.push({ + uuid: providerConfig.uuid, + success: false, + modelName: healthResult.modelName, + message: healthResult.errorMessage || '检测失败' + }); + } + } catch (error) { + providerPoolManager.markProviderUnhealthy(providerType, providerConfig, error.message); + results.push({ + uuid: providerConfig.uuid, + success: false, + message: error.message + }); + } + } + + // 保存更新后的状态到文件 + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + + // 从 providerStatus 构建 providerPools 对象并保存 + const providerPools = {}; + for (const pType in providerPoolManager.providerStatus) { + providerPools[pType] = providerPoolManager.providerStatus[pType].map(ps => ps.config); + } + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); + + const successCount = results.filter(r => r.success === true).length; + const failCount = results.filter(r => r.success === false).length; + + console.log(`[UI API] Health check completed for ${providerType}: ${successCount} healthy, ${failCount} unhealthy`); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'health_check', + filePath: filePath, + providerType, + results, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `健康检测完成: ${successCount} 个健康, ${failCount} 个异常`, + successCount, + failCount, + totalCount: providers.length, + results + })); + return true; + } catch (error) { + console.error('[UI API] Health check error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } + } + // Generate OAuth authorization URL for providers const generateAuthUrlMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/generate-auth-url$/); if (method === 'POST' && generateAuthUrlMatch) { @@ -1309,6 +1420,125 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Quick link Kiro config to claude-kiro-oauth + if (method === 'POST' && pathParam === '/api/quick-link-kiro') { + try { + const body = await getRequestBody(req); + const { filePath } = body; + + if (!filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'filePath is required' } })); + return true; + } + + const providerType = 'claude-kiro-oauth'; + const defaultCheckModel = 'claude-haiku-4-5'; + const poolsFilePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + + // Load existing pools + let providerPools = {}; + if (existsSync(poolsFilePath)) { + try { + const fileContent = readFileSync(poolsFilePath, 'utf8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + console.warn('[UI API] Failed to read existing provider pools:', readError.message); + } + } + + // Ensure claude-kiro-oauth array exists + if (!providerPools[providerType]) { + providerPools[providerType] = []; + } + + // Check if already linked + const normalizedPath = filePath.replace(/\\/g, '/'); + const isAlreadyLinked = providerPools[providerType].some(p => { + if (!p.KIRO_OAUTH_CREDS_FILE_PATH) return false; + const existingPath = p.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); + return existingPath === normalizedPath || + existingPath === './' + normalizedPath || + './' + existingPath === normalizedPath; + }); + + if (isAlreadyLinked) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: '该配置文件已关联' } })); + return true; + } + + // Generate UUID + const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + + // Create new provider config + const newProvider = { + KIRO_OAUTH_CREDS_FILE_PATH: normalizedPath.startsWith('./') ? normalizedPath : './' + normalizedPath, + uuid: uuid, + checkModelName: defaultCheckModel, + checkHealth: true, + isHealthy: true, + isDisabled: false, + lastUsed: null, + usageCount: 0, + errorCount: 0, + lastErrorTime: null, + lastHealthCheckTime: null, + lastHealthCheckModel: null, + lastErrorMessage: null + }; + + providerPools[providerType].push(newProvider); + + // Save to file + writeFileSync(poolsFilePath, JSON.stringify(providerPools, null, 2), 'utf8'); + console.log(`[UI API] Quick linked Kiro config: ${filePath} -> ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // Broadcast update event + broadcastEvent('config_update', { + action: 'quick_link', + filePath: poolsFilePath, + providerType, + newProvider, + timestamp: new Date().toISOString() + }); + + broadcastEvent('provider_update', { + action: 'add', + providerType, + providerConfig: newProvider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: '配置已成功关联到 claude-kiro-oauth', + provider: newProvider + })); + return true; + } catch (error) { + console.error('[UI API] Quick link failed:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '关联失败: ' + error.message + } + })); + return true; + } + } + // Reload configuration files if (method === 'POST' && pathParam === '/api/reload-config') { try { diff --git a/static/app/modal.js b/static/app/modal.js index f2a717b..e0dc45a 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -3,6 +3,13 @@ import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js'; import { handleProviderPasswordToggle } from './event-handlers.js'; +// 分页配置 +const PROVIDERS_PER_PAGE = 20; +let currentPage = 1; +let currentProviders = []; +let currentProviderType = ''; +let cachedModels = []; // 缓存模型列表 + /** * 显示提供商管理模态框 * @param {Object} data - 提供商数据 @@ -10,6 +17,12 @@ import { handleProviderPasswordToggle } from './event-handlers.js'; function showProviderManagerModal(data) { const { providerType, providers, totalCount, healthyCount } = data; + // 保存当前数据用于分页 + currentProviders = providers; + currentProviderType = providerType; + currentPage = 1; + cachedModels = []; + // 移除已存在的模态框 const existingModal = document.querySelector('.provider-modal'); if (existingModal) { @@ -20,6 +33,8 @@ function showProviderManagerModal(data) { existingModal.remove(); } + const totalPages = Math.ceil(providers.length / PROVIDERS_PER_PAGE); + // 创建模态框 const modal = document.createElement('div'); modal.className = 'provider-modal'; @@ -49,12 +64,19 @@ function showProviderManagerModal(data) { + + ${totalPages > 1 ? renderPagination(1, totalPages, providers.length) : ''} +
- ${renderProviderList(providers)} + ${renderProviderListPaginated(providers, 1)}
+ + ${totalPages > 1 ? renderPagination(1, totalPages, providers.length, 'bottom') : ''} `; @@ -66,20 +88,158 @@ function showProviderManagerModal(data) { addModalEventListeners(modal); // 先获取该提供商类型的模型列表(只调用一次API) - loadModelsForProviderType(providerType, providers); + const pageProviders = providers.slice(0, PROVIDERS_PER_PAGE); + loadModelsForProviderType(providerType, pageProviders); } /** - * 为提供商类型加载模型列表(优化:只调用一次API) + * 渲染分页控件 + * @param {number} currentPage - 当前页码 + * @param {number} totalPages - 总页数 + * @param {number} totalItems - 总条目数 + * @param {string} position - 位置标识 (top/bottom) + * @returns {string} HTML字符串 + */ +function renderPagination(page, totalPages, totalItems, position = 'top') { + const startItem = (page - 1) * PROVIDERS_PER_PAGE + 1; + const endItem = Math.min(page * PROVIDERS_PER_PAGE, totalItems); + + // 生成页码按钮 + let pageButtons = ''; + const maxVisiblePages = 5; + let startPage = Math.max(1, page - Math.floor(maxVisiblePages / 2)); + let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + if (endPage - startPage < maxVisiblePages - 1) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + if (startPage > 1) { + pageButtons += ``; + if (startPage > 2) { + pageButtons += `...`; + } + } + + for (let i = startPage; i <= endPage; i++) { + pageButtons += ``; + } + + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + pageButtons += `...`; + } + pageButtons += ``; + } + + return ` +
+
+ 显示 ${startItem}-${endItem} / 共 ${totalItems} 条 +
+
+ + ${pageButtons} + +
+
+ 跳转到 + + +
+
+ `; +} + +/** + * 跳转到指定页 + * @param {number} page - 目标页码 + */ +function goToProviderPage(page) { + const totalPages = Math.ceil(currentProviders.length / PROVIDERS_PER_PAGE); + + // 验证页码范围 + if (page < 1) page = 1; + if (page > totalPages) page = totalPages; + + currentPage = page; + + // 更新提供商列表 + const providerList = document.getElementById('providerList'); + if (providerList) { + providerList.innerHTML = renderProviderListPaginated(currentProviders, page); + } + + // 更新分页控件 + const paginationContainers = document.querySelectorAll('.pagination-container'); + paginationContainers.forEach(container => { + const position = container.getAttribute('data-position'); + container.outerHTML = renderPagination(page, totalPages, currentProviders.length, position); + }); + + // 滚动到顶部 + const modalBody = document.querySelector('.provider-modal-body'); + if (modalBody) { + modalBody.scrollTop = 0; + } + + // 为当前页的提供商加载模型列表 + const startIndex = (page - 1) * PROVIDERS_PER_PAGE; + const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, currentProviders.length); + const pageProviders = currentProviders.slice(startIndex, endIndex); + + // 如果已缓存模型列表,直接使用 + if (cachedModels.length > 0) { + pageProviders.forEach(provider => { + renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []); + }); + } else { + loadModelsForProviderType(currentProviderType, pageProviders); + } +} + +/** + * 渲染分页后的提供商列表 + * @param {Array} providers - 提供商数组 + * @param {number} page - 当前页码 + * @returns {string} HTML字符串 + */ +function renderProviderListPaginated(providers, page) { + const startIndex = (page - 1) * PROVIDERS_PER_PAGE; + const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, providers.length); + const pageProviders = providers.slice(startIndex, endIndex); + + return renderProviderList(pageProviders); +} + +/** + * 为提供商类型加载模型列表(优化:只调用一次API,并缓存结果) * @param {string} providerType - 提供商类型 * @param {Array} providers - 提供商列表 */ async function loadModelsForProviderType(providerType, providers) { try { + // 如果已有缓存,直接使用 + if (cachedModels.length > 0) { + providers.forEach(provider => { + renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []); + }); + return; + } + // 只调用一次API获取模型列表 const response = await window.apiClient.get(`/provider-models/${encodeURIComponent(providerType)}`); const models = response.models || []; + // 缓存模型列表 + cachedModels = models; + // 为每个提供商渲染模型选择器 providers.forEach(provider => { renderNotSupportedModelsSelector(provider.uuid, models, provider.notSupportedModels || []); @@ -192,6 +352,8 @@ function renderProviderList(providers) { const isHealthy = provider.isHealthy; const isDisabled = provider.isDisabled || false; const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : '从未使用'; + const lastHealthCheckTime = provider.lastHealthCheckTime ? new Date(provider.lastHealthCheckTime).toLocaleString() : '从未检测'; + const lastHealthCheckModel = provider.lastHealthCheckModel || '-'; const healthClass = isHealthy ? 'healthy' : 'unhealthy'; const disabledClass = isDisabled ? 'disabled' : ''; const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning'; @@ -202,6 +364,19 @@ function renderProviderList(providers) { const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban'; const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; + // 构建错误信息显示 + let errorInfoHtml = ''; + if (!isHealthy && provider.lastErrorMessage) { + const escapedErrorMsg = provider.lastErrorMessage.replace(//g, '>'); + errorInfoHtml = ` +
+ + 最后错误: + ${escapedErrorMsg} +
+ `; + } + return `
@@ -220,6 +395,17 @@ function renderProviderList(providers) { 失败次数: ${provider.errorCount || 0} | 最后使用: ${lastUsed}
+
+ + + 最后检测: ${lastHealthCheckTime} + | + + + 检测模型: ${lastHealthCheckModel} + +
+ ${errorInfoHtml}
` : ''; item.innerHTML = `
@@ -90,6 +97,7 @@ function createConfigItemElement(config, index) {
${statusText} + ${quickLinkBtnHtml}
@@ -141,6 +149,15 @@ function createConfigItemElement(config, index) { }); } + // 一键关联按钮事件 + const quickLinkBtn = item.querySelector('.btn-quick-link'); + if (quickLinkBtn) { + quickLinkBtn.addEventListener('click', (e) => { + e.stopPropagation(); + quickLinkKiroConfig(config.path); + }); + } + // 添加点击事件展开/折叠详情 item.addEventListener('click', (e) => { if (!e.target.closest('.config-item-actions')) { @@ -706,6 +723,12 @@ function initUploadConfigManager() { refreshBtn.addEventListener('click', loadConfigList); } + // 批量关联 kiro 配置按钮 + const batchLinkKiroBtn = document.getElementById('batchLinkKiroBtn'); + if (batchLinkKiroBtn) { + batchLinkKiroBtn.addEventListener('click', batchLinkKiroConfigs); + } + // 初始加载配置列表 loadConfigList(); } @@ -738,6 +761,74 @@ async function reloadConfig() { } } +/** + * 一键关联 Kiro 配置到 claude-kiro-oauth + * @param {string} filePath - 配置文件路径 + */ +async function quickLinkKiroConfig(filePath) { + try { + showToast('正在关联配置...', 'info'); + + const result = await window.apiClient.post('/quick-link-kiro', { + filePath: filePath + }); + + showToast(result.message || '配置关联成功', 'success'); + + // 刷新配置列表 + await loadConfigList(); + } catch (error) { + console.error('一键关联失败:', error); + showToast('关联失败: ' + error.message, 'error'); + } +} + +/** + * 批量关联 configs/kiro/ 下的未关联配置到 kiro-oauth + */ +async function batchLinkKiroConfigs() { + // 筛选出 configs/kiro/ 下的未关联配置 + const kiroUnlinkedConfigs = allConfigs.filter(config => + !config.isUsed && config.path.toLowerCase().includes('configs/kiro/') + ); + + if (kiroUnlinkedConfigs.length === 0) { + showToast('没有需要关联的 configs/kiro/ 配置', 'info'); + return; + } + + const confirmMsg = `确定要批量关联 ${kiroUnlinkedConfigs.length} 个 configs/kiro/ 下的配置吗?`; + if (!confirm(confirmMsg)) { + return; + } + + showToast(`正在批量关联 ${kiroUnlinkedConfigs.length} 个配置...`, 'info'); + + let successCount = 0; + let failCount = 0; + + for (const config of kiroUnlinkedConfigs) { + try { + await window.apiClient.post('/quick-link-kiro', { + filePath: config.path + }); + successCount++; + } catch (error) { + console.error(`关联失败: ${config.path}`, error); + failCount++; + } + } + + // 刷新配置列表 + await loadConfigList(); + + if (failCount === 0) { + showToast(`成功关联 ${successCount} 个配置`, 'success'); + } else { + showToast(`关联完成: 成功 ${successCount} 个, 失败 ${failCount} 个`, 'warning'); + } +} + /** * 防抖函数 * @param {Function} func - 要防抖的函数 diff --git a/static/index.html b/static/index.html index 7d99511..4ceca10 100644 --- a/static/index.html +++ b/static/index.html @@ -768,6 +768,9 @@ 共 0 个配置文件 已关联: 0 未关联: 0 +