commit
f4b30ed596
13 changed files with 1668 additions and 128 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -5,4 +5,6 @@ CLAUDE.md
|
|||
config.json
|
||||
provider_pools.json
|
||||
fetch_system_prompt.txt
|
||||
input_system_prompt.txt
|
||||
input_system_prompt.txt
|
||||
pwd
|
||||
import-accounts.ps1
|
||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{
|
||||
}
|
||||
166
run-docker.ps1
Normal file
166
run-docker.ps1
Normal file
|
|
@ -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键退出"
|
||||
314
run-docker.sh
314
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 "脚本执行完成"
|
||||
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 "脚本执行完成"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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': [],
|
||||
|
|
|
|||
|
|
@ -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<boolean|null>} - 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 {
|
||||
|
|
|
|||
|
|
@ -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<Object>} 更新后的 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<boolean>} 是否有效
|
||||
*/
|
||||
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<Object>} 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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<button class="btn btn-warning" onclick="window.resetAllProvidersHealth('${providerType}')" title="将所有节点的健康状态重置为健康">
|
||||
<i class="fas fa-heartbeat"></i> 重置为健康
|
||||
</button>
|
||||
<button class="btn btn-info" onclick="window.performHealthCheck('${providerType}')" title="对所有节点执行健康检测">
|
||||
<i class="fas fa-stethoscope"></i> 健康检测
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${totalPages > 1 ? renderPagination(1, totalPages, providers.length) : ''}
|
||||
|
||||
<div class="provider-list" id="providerList">
|
||||
${renderProviderList(providers)}
|
||||
${renderProviderListPaginated(providers, 1)}
|
||||
</div>
|
||||
|
||||
${totalPages > 1 ? renderPagination(1, totalPages, providers.length, 'bottom') : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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 += `<button class="page-btn" onclick="window.goToProviderPage(1)">1</button>`;
|
||||
if (startPage > 2) {
|
||||
pageButtons += `<span class="page-ellipsis">...</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pageButtons += `<button class="page-btn ${i === page ? 'active' : ''}" onclick="window.goToProviderPage(${i})">${i}</button>`;
|
||||
}
|
||||
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) {
|
||||
pageButtons += `<span class="page-ellipsis">...</span>`;
|
||||
}
|
||||
pageButtons += `<button class="page-btn" onclick="window.goToProviderPage(${totalPages})">${totalPages}</button>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="pagination-container ${position}" data-position="${position}">
|
||||
<div class="pagination-info">
|
||||
显示 ${startItem}-${endItem} / 共 ${totalItems} 条
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button class="page-btn nav-btn" onclick="window.goToProviderPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
${pageButtons}
|
||||
<button class="page-btn nav-btn" onclick="window.goToProviderPage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pagination-jump">
|
||||
<span>跳转到</span>
|
||||
<input type="number" min="1" max="${totalPages}" value="${page}"
|
||||
onkeypress="if(event.key==='Enter')window.goToProviderPage(parseInt(this.value))"
|
||||
class="page-jump-input">
|
||||
<span>页</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定页
|
||||
* @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, '<').replace(/>/g, '>');
|
||||
errorInfoHtml = `
|
||||
<div class="provider-error-info">
|
||||
<i class="fas fa-exclamation-circle text-danger"></i>
|
||||
<span class="error-label">最后错误:</span>
|
||||
<span class="error-message" title="${escapedErrorMsg}">${escapedErrorMsg}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}">
|
||||
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
|
||||
|
|
@ -220,6 +395,17 @@ function renderProviderList(providers) {
|
|||
失败次数: ${provider.errorCount || 0} |
|
||||
最后使用: ${lastUsed}
|
||||
</div>
|
||||
<div class="provider-health-meta">
|
||||
<span class="health-check-time">
|
||||
<i class="fas fa-clock"></i>
|
||||
最后检测: ${lastHealthCheckTime}
|
||||
</span> |
|
||||
<span class="health-check-model">
|
||||
<i class="fas fa-cube"></i>
|
||||
检测模型: ${lastHealthCheckModel}
|
||||
</span>
|
||||
</div>
|
||||
${errorInfoHtml}
|
||||
</div>
|
||||
<div class="provider-actions-group">
|
||||
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${provider.uuid}', event)" title="${toggleButtonText}此提供商">
|
||||
|
|
@ -442,11 +628,15 @@ function renderProviderConfig(provider) {
|
|||
function getFieldOrder(provider) {
|
||||
const orderedFields = ['checkModelName', 'checkHealth'];
|
||||
|
||||
// 需要排除的内部状态字段
|
||||
const excludedFields = [
|
||||
'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime',
|
||||
'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage'
|
||||
];
|
||||
|
||||
// 获取所有其他配置项
|
||||
const otherFields = Object.keys(provider).filter(key =>
|
||||
key !== 'isHealthy' && key !== 'lastUsed' && key !== 'usageCount' &&
|
||||
key !== 'errorCount' && key !== 'lastErrorTime' && key !== 'uuid' &&
|
||||
key !== 'isDisabled' && !orderedFields.includes(key)
|
||||
!excludedFields.includes(key) && !orderedFields.includes(key)
|
||||
);
|
||||
|
||||
// 按字母顺序排序其他字段
|
||||
|
|
@ -687,6 +877,10 @@ async function refreshProviderConfig(providerType) {
|
|||
// 如果当前显示的是该提供商类型的模态框,则更新模态框
|
||||
const modal = document.querySelector('.provider-modal');
|
||||
if (modal && modal.getAttribute('data-provider-type') === providerType) {
|
||||
// 更新缓存的提供商数据
|
||||
currentProviders = data.providers;
|
||||
currentProviderType = providerType;
|
||||
|
||||
// 更新统计信息
|
||||
const totalCountElement = modal.querySelector('.provider-summary-item .value');
|
||||
if (totalCountElement) {
|
||||
|
|
@ -698,14 +892,46 @@ async function refreshProviderConfig(providerType) {
|
|||
healthyCountElement.textContent = data.healthyCount;
|
||||
}
|
||||
|
||||
// 重新渲染提供商列表
|
||||
const providerList = modal.querySelector('.provider-list');
|
||||
if (providerList) {
|
||||
providerList.innerHTML = renderProviderList(data.providers);
|
||||
const totalPages = Math.ceil(data.providers.length / PROVIDERS_PER_PAGE);
|
||||
|
||||
// 确保当前页不超过总页数
|
||||
if (currentPage > totalPages) {
|
||||
currentPage = Math.max(1, totalPages);
|
||||
}
|
||||
|
||||
// 重新加载模型列表
|
||||
loadModelsForProviderType(providerType, data.providers);
|
||||
// 重新渲染提供商列表(分页)
|
||||
const providerList = modal.querySelector('.provider-list');
|
||||
if (providerList) {
|
||||
providerList.innerHTML = renderProviderListPaginated(data.providers, currentPage);
|
||||
}
|
||||
|
||||
// 更新分页控件
|
||||
const paginationContainers = modal.querySelectorAll('.pagination-container');
|
||||
if (totalPages > 1) {
|
||||
paginationContainers.forEach(container => {
|
||||
const position = container.getAttribute('data-position');
|
||||
container.outerHTML = renderPagination(currentPage, totalPages, data.providers.length, position);
|
||||
});
|
||||
|
||||
// 如果之前没有分页控件,需要添加
|
||||
if (paginationContainers.length === 0) {
|
||||
const modalBody = modal.querySelector('.provider-modal-body');
|
||||
const providerListEl = modal.querySelector('.provider-list');
|
||||
if (modalBody && providerListEl) {
|
||||
providerListEl.insertAdjacentHTML('beforebegin', renderPagination(currentPage, totalPages, data.providers.length, 'top'));
|
||||
providerListEl.insertAdjacentHTML('afterend', renderPagination(currentPage, totalPages, data.providers.length, 'bottom'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果只有一页,移除分页控件
|
||||
paginationContainers.forEach(container => container.remove());
|
||||
}
|
||||
|
||||
// 重新加载当前页的模型列表
|
||||
const startIndex = (currentPage - 1) * PROVIDERS_PER_PAGE;
|
||||
const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, data.providers.length);
|
||||
const pageProviders = data.providers.slice(startIndex, endIndex);
|
||||
loadModelsForProviderType(providerType, pageProviders);
|
||||
}
|
||||
|
||||
// 同时更新主界面的提供商统计数据
|
||||
|
|
@ -1037,6 +1263,49 @@ async function resetAllProvidersHealth(providerType) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行健康检测
|
||||
* @param {string} providerType - 提供商类型
|
||||
*/
|
||||
async function performHealthCheck(providerType) {
|
||||
if (!confirm(`确定要对 ${providerType} 的所有节点执行健康检测吗?\n\n这将向每个节点发送测试请求来验证其可用性。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showToast('正在执行健康检测,请稍候...', 'info');
|
||||
|
||||
const response = await window.apiClient.post(
|
||||
`/providers/${encodeURIComponent(providerType)}/health-check`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
const { successCount, failCount, totalCount, results } = response;
|
||||
|
||||
// 统计跳过的数量(checkHealth 未启用的)
|
||||
const skippedCount = results ? results.filter(r => r.success === null).length : 0;
|
||||
|
||||
let message = `健康检测完成: ${successCount} 健康`;
|
||||
if (failCount > 0) message += `, ${failCount} 异常`;
|
||||
if (skippedCount > 0) message += `, ${skippedCount} 跳过(未启用)`;
|
||||
|
||||
showToast(message, failCount > 0 ? 'warning' : 'success');
|
||||
|
||||
// 重新加载配置
|
||||
await window.apiClient.post('/reload-config');
|
||||
|
||||
// 刷新提供商配置显示
|
||||
await refreshProviderConfig(providerType);
|
||||
} else {
|
||||
showToast('健康检测失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('健康检测失败:', error);
|
||||
showToast(`健康检测失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染不支持的模型选择器(不调用API,直接使用传入的模型列表)
|
||||
* @param {string} uuid - 提供商UUID
|
||||
|
|
@ -1087,8 +1356,10 @@ export {
|
|||
addProvider,
|
||||
toggleProviderStatus,
|
||||
resetAllProvidersHealth,
|
||||
performHealthCheck,
|
||||
loadModelsForProviderType,
|
||||
renderNotSupportedModelsSelector
|
||||
renderNotSupportedModelsSelector,
|
||||
goToProviderPage
|
||||
};
|
||||
|
||||
// 将函数挂载到window对象
|
||||
|
|
@ -1101,4 +1372,6 @@ window.deleteProvider = deleteProvider;
|
|||
window.showAddProviderForm = showAddProviderForm;
|
||||
window.addProvider = addProvider;
|
||||
window.toggleProviderStatus = toggleProviderStatus;
|
||||
window.resetAllProvidersHealth = resetAllProvidersHealth;
|
||||
window.resetAllProvidersHealth = resetAllProvidersHealth;
|
||||
window.performHealthCheck = performHealthCheck;
|
||||
window.goToProviderPage = goToProviderPage;
|
||||
|
|
@ -1409,6 +1409,49 @@ input:checked + .toggle-slider:before {
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.provider-health-meta {
|
||||
font-size: 12px;
|
||||
color: #8b95a5;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.provider-health-meta i {
|
||||
margin-right: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.provider-error-info {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #fed7d7 100%);
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-error-info i {
|
||||
color: #e53e3e;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.provider-error-info .error-label {
|
||||
color: #c53030;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-error-info .error-message {
|
||||
color: #742a2a;
|
||||
word-break: break-word;
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.provider-actions-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -1445,6 +1488,59 @@ input:checked + .toggle-slider:before {
|
|||
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-quick-link {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-quick-link:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
}
|
||||
|
||||
.btn-quick-link i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.btn-batch-link-kiro {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-batch-link-kiro:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
}
|
||||
|
||||
.btn-batch-link-kiro i {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
|
||||
|
|
@ -2810,6 +2906,21 @@ input:checked + .toggle-slider:before {
|
|||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 健康检测按钮样式 */
|
||||
.btn-info {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-info:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 提供商状态指示器 */
|
||||
.provider-status .disabled-indicator {
|
||||
position: relative;
|
||||
|
|
@ -3414,3 +3525,139 @@ input:checked + .toggle-slider:before {
|
|||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ==================== 分页控件样式 ==================== */
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
margin: 12px 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination-container.top {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.pagination-container.bottom {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-btn.nav-btn {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.page-ellipsis {
|
||||
padding: 0 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pagination-jump {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.page-jump-input {
|
||||
width: 60px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-jump-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
/* 移除数字输入框的上下箭头 */
|
||||
.page-jump-input::-webkit-outer-spin-button,
|
||||
.page-jump-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-jump-input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* 响应式分页 */
|
||||
@media (max-width: 768px) {
|
||||
.pagination-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination-jump {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,13 @@ function createConfigItemElement(config, index) {
|
|||
|
||||
// 生成关联详情HTML
|
||||
const usageInfoHtml = generateUsageInfoHtml(config);
|
||||
|
||||
// 判断是否可以一键关联(未关联且路径包含 configs/kiro/)
|
||||
const canQuickLink = !config.isUsed && config.path.toLowerCase().includes('configs/kiro/');
|
||||
const quickLinkBtnHtml = canQuickLink ?
|
||||
`<button class="btn-quick-link" data-path="${config.path}" title="一键关联到 claude-kiro-oauth">
|
||||
<i class="fas fa-link"></i> kiro-oauth
|
||||
</button>` : '';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="config-item-header">
|
||||
|
|
@ -90,6 +97,7 @@ function createConfigItemElement(config, index) {
|
|||
<div class="config-item-status">
|
||||
<i class="fas ${statusIcon}"></i>
|
||||
${statusText}
|
||||
${quickLinkBtnHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item-details">
|
||||
|
|
@ -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 - 要防抖的函数
|
||||
|
|
|
|||
|
|
@ -768,6 +768,9 @@
|
|||
<span id="configCount">共 0 个配置文件</span>
|
||||
<span id="usedConfigCount" class="status-used">已关联: 0</span>
|
||||
<span id="unusedConfigCount" class="status-unused">未关联: 0</span>
|
||||
<button id="batchLinkKiroBtn" class="btn-batch-link-kiro" title="批量关联 configs/kiro/ 下的未关联配置">
|
||||
<i class="fas fa-link"></i> 自动关联到 kiro-oauth
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="configList" class="config-list">
|
||||
|
|
|
|||
Loading…
Reference in a new issue