🎨 增加并发访问
This commit is contained in:
54
.dockerignore
Normal file
54
.dockerignore
Normal file
@@ -0,0 +1,54 @@
|
||||
# Rust build artifacts
|
||||
target/
|
||||
Cargo.lock
|
||||
|
||||
# Node.js dependencies and build artifacts
|
||||
admin-ui/node_modules/
|
||||
admin-ui/dist/
|
||||
admin-ui/pnpm-lock.yaml
|
||||
admin-ui/tsconfig.tsbuildinfo
|
||||
admin-ui/.vite/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# IDE and editor files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Claude/AI
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
|
||||
# Documentation and examples
|
||||
*.md
|
||||
README.md
|
||||
config.example.json
|
||||
credentials.example.*.json
|
||||
|
||||
# Development and test files
|
||||
src/test.rs
|
||||
src/debug.rs
|
||||
test.json
|
||||
tools/
|
||||
|
||||
# OS-specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local configuration (keep templates only)
|
||||
config.json
|
||||
credentials.json
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
/target
|
||||
|
||||
/config.json
|
||||
/credentials.json
|
||||
/.idea
|
||||
/test.json
|
||||
|
||||
/admin-ui/node_modules/
|
||||
/admin-ui/dist/
|
||||
/admin-ui/pnpm-lock.yaml
|
||||
/admin-ui/package-lock.json
|
||||
/admin-ui/tsconfig.tsbuildinfo
|
||||
|
||||
.claude/
|
||||
!.claude/skills/
|
||||
|
||||
/credentials.*
|
||||
|
||||
*.json
|
||||
logs/
|
||||
data/
|
||||
|
||||
/kiro_balance_cache.json
|
||||
/kiro_stats.json
|
||||
|
||||
refs/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
config/
|
||||
779
CHANGELOG.md
Normal file
779
CHANGELOG.md
Normal file
@@ -0,0 +1,779 @@
|
||||
# Changelog
|
||||
|
||||
## [v1.1.5] - 2026-03-02
|
||||
|
||||
### Fixed
|
||||
- **防止孤立 tool_result 导致空消息** — `convert_user_message` 新增最终兜底逻辑,当 tool_result 被过滤为孤立块后 content 变空时插入占位符 ".",避免上游返回 400 "Improperly formed request";新增回归测试覆盖孤立 tool_result 场景 (`src/anthropic/converter.rs`)
|
||||
|
||||
## [v1.1.4] - 2026-03-02
|
||||
|
||||
### Fixed
|
||||
- **WebSearch 历史消息上下文保留** — `convert_assistant_message` 新增对 `server_tool_use`(忽略)和 `web_search_tool_result`(提取 title、url、snippet、page_age 为纯文本)的处理,修复多轮对话中搜索结果被静默丢弃导致 Kiro 丢失上下文的问题;使用纯文本格式彻底规避特殊字符破坏格式的风险;新增 2 个单元测试覆盖上述路径 (`src/anthropic/converter.rs`)
|
||||
|
||||
## [v1.1.3] - 2026-02-27
|
||||
|
||||
### Changed
|
||||
- **合并三个导入对话框为一个** — 删除冗余的 `KamImportDialog` 和 `BatchImportDialog`,统一使用 `ImportTokenJsonDialog`,自动识别 KAM 嵌套格式、扁平凭据格式和 Token JSON 格式 (`admin-ui/src/components/`)
|
||||
- **上游网络错误归类为瞬态错误** — `error sending request`/`connection closed`/`connection reset` 纳入 `is_transient_upstream_error`,返回 502 且不输出请求体 (`src/anthropic/handlers.rs`)
|
||||
- **上游报错不再输出完整请求体** — `sensitive-logs` 模式下仅输出请求体字节数 (`src/anthropic/handlers.rs`)
|
||||
- **瞬态错误匹配大小写归一化** — `is_transient_upstream_error` 统一 `.to_lowercase()` 后匹配,提取 `NETWORK_ERROR_PATTERNS` 常量消除重复 (`src/anthropic/handlers.rs`)
|
||||
|
||||
### Removed
|
||||
- **移除负载均衡模式切换** — 删除前端按钮/API/hooks 和后端路由/handler/service/types/config 字段/token_manager 方法及测试,该设置实际未被使用 (`admin-ui/`, `src/admin/`, `src/kiro/token_manager.rs`, `src/model/config.rs`)
|
||||
|
||||
### Added
|
||||
- **Usage 诊断日志** — 流式/缓冲流式/非流式三条路径均增加 `sensitive-logs` 保护的 usage 日志,输出 estimated/context/final input_tokens 及来源、output_tokens (`src/anthropic/stream.rs`, `src/anthropic/handlers.rs`)
|
||||
|
||||
## [v1.1.2] - 2026-02-27
|
||||
|
||||
### Added
|
||||
- **Kiro Account Manager 导入** — 支持导入 KAM 导出的 JSON 凭据文件 (`admin-ui/src/components/kam-import-dialog.tsx`)
|
||||
- **批量导入对话框** — 新增独立的批量导入凭据组件 (`admin-ui/src/components/batch-import-dialog.tsx`)
|
||||
- **凭证 disabled 字段持久化** — 从配置文件读取 disabled 状态,支持手动禁用凭据跨重启保留 (`src/kiro/model/credentials.rs`, `src/kiro/token_manager.rs`)
|
||||
- **凭据级 Region 编辑** — Admin UI 支持对已有凭据在线修改 `region` 和 `apiRegion`,点击凭据卡片内联编辑,保存后持久化 (`src/admin/`, `admin-ui/src/components/credential-card.tsx`)
|
||||
- **Admin API `POST /credentials/:id/region`** — 新增 Region 修改接口,支持清除(传 null)或覆盖两个 region 字段 (`src/admin/handlers.rs`, `src/admin/router.rs`)
|
||||
|
||||
### Fixed
|
||||
- **WebSearch SSE 事件序列修正** — 调整 server_tool_use 位置、content block index、page_age 转换、usage 统计 (`src/anthropic/websearch.rs`)
|
||||
- **Token Manager 统计回写** — 立即回写统计数据,清除已删除凭据残留 (`src/kiro/token_manager.rs`)
|
||||
- **HTTP 非安全地址批量导入** — 修复 admin-ui 在 HTTP 下的导入错误 (`admin-ui/src/lib/utils.ts`)
|
||||
- **Docker 端口绑定优化** — 修正端口绑定和配置目录挂载 (`docker-compose.yml`)
|
||||
- **移除重复 Sonnet 4.6 模型项** — 删除 `/v1/models` 中重复的 claude-sonnet-4-6 条目,避免 id 冲突 (`src/anthropic/handlers.rs`)
|
||||
- **防止自动禁用状态被持久化** — `persist_credentials()` 仅持久化手动禁用,避免重启后自动禁用变为手动禁用导致无法自愈 (`src/kiro/token_manager.rs`)
|
||||
- **sha256Hex digest 异常回退** — 在 `crypto.subtle.digest` 外围加 try/catch,失败时回退到纯 JS 实现 (`admin-ui/src/lib/utils.ts`)
|
||||
- **parseKamJson null 输入保护** — 对 JSON null 输入增加类型检查,避免 TypeError (`admin-ui/src/components/kam-import-dialog.tsx`)
|
||||
- **额度查询 region 修复** — `get_usage_limits` 改用 `effective_api_region`,凭据指定 region 时不再因走错 endpoint 而 403 (`src/kiro/token_manager.rs`)
|
||||
- **批量导入丢失 apiRegion** — `TokenJsonItem` 补充 `api_region` 字段,导入 JSON 中的 `apiRegion` 不再被丢弃 (`src/admin/types.rs`, `src/admin/service.rs`)
|
||||
- **API 请求使用凭据级 region** — `provider.rs` 的 `base_url`/`mcp_url`/`base_domain` 改用 `credentials.effective_api_region()`,凭据配置了 region 时不再错误地走全局 config region 导致 403 (`src/kiro/provider.rs`)
|
||||
- **Region 编辑 stale state** — 点击编辑 Region 时同步最新 props 到 input,避免后台刷新后提交旧值覆盖服务端数据 (`admin-ui/src/components/credential-card.tsx`)
|
||||
- **Region 值未 trim** — `set_region` 保存前对 region/apiRegion 做 trim,防止带空格的值持久化后生成无效 URL (`src/admin/service.rs`)
|
||||
- **过滤超长工具名** — `convert_tools` 过滤掉 name 超过 64 字符的工具,避免上游拒绝整个请求 (`src/anthropic/converter.rs`)
|
||||
- **429 错误不再输出完整请求体** — 瞬态上游错误(429/5xx)走独立分支返回 429,不触发 sensitive-logs 的请求体诊断日志 (`src/anthropic/handlers.rs`)
|
||||
- **兼容旧 authRegion 配置** — `credentials.region` 增加 `#[serde(alias = "authRegion")]`,旧配置文件中的 `authRegion` 字段不再被静默忽略 (`src/kiro/model/credentials.rs`)
|
||||
- **导入凭据 region 规范化** — token.json 导入路径对 region/apiRegion 做 trim + 空字符串转 None,与 `set_region` 逻辑一致 (`src/admin/service.rs`)
|
||||
|
||||
### Changed
|
||||
- **默认 kiro_version 更新至 0.10.0** (`src/model/config.rs`)
|
||||
- **Opus 模型映射调整** — opus 默认映射到 claude-opus-4.6,仅 4.5/4-5 显式映射到 claude-opus-4.5 (`src/anthropic/converter.rs`)
|
||||
- **Sonnet 4.6 Model 字段补全** — 添加 context_length、max_completion_tokens、thinking 字段 (`src/anthropic/handlers.rs`)
|
||||
- **Region 配置精简** — 删除 `credentials.auth_region` 和 `config.auth_region` 冗余字段;凭据的 `region` 同时用于 Token 刷新和 API 请求默认值,`api_region` 可单独覆盖 API 路由 (`src/kiro/model/credentials.rs`, `src/model/config.rs`)
|
||||
- **添加凭据 UI Region 字段语义调整** — 前端"Auth Region"改为"Region"(对应 `credentials.region`),"API Region"保持,去除无意义的 `authRegion` 前端字段 (`admin-ui/`)
|
||||
|
||||
## [v1.1.1] - 2026-02-18
|
||||
|
||||
### Fixed
|
||||
- **修复 Sonnet 4.6 thinking 配置变量名错误** (`src/anthropic/handlers.rs`)
|
||||
- 修正 thinking 配置覆写逻辑中的变量名拼写错误
|
||||
|
||||
## [v1.1.0] - 2026-02-18
|
||||
|
||||
### Added
|
||||
- **Sonnet 4.6 模型支持** (`src/anthropic/handlers.rs`, `src/anthropic/converter.rs`, `src/anthropic/types.rs`)
|
||||
- 添加 claude-sonnet-4-6 及其 thinking/agentic 变体到模型列表
|
||||
- 更新模型映射逻辑以正确识别 Sonnet 4.6 版本
|
||||
- 为 Sonnet 4.6 启用 1M 上下文窗口和 64K 最大输出 tokens
|
||||
- 更新 thinking 配置覆写逻辑以支持 Sonnet 4.6 的 adaptive thinking
|
||||
|
||||
## [v1.0.21] - 2026-02-17
|
||||
|
||||
### Changed
|
||||
- **图片处理限制放宽** (`src/model/config.rs`)
|
||||
- 单图总像素限制从 1.15M 放宽至 4M,支持更高分辨率图片直接透传
|
||||
- 图片长边限制从 1568 放宽至 4000,减少不必要的缩放压缩
|
||||
|
||||
## [v1.0.20] - 2026-02-17
|
||||
|
||||
### Fixed
|
||||
- **空消息内容验证与错误分类改进** (`src/anthropic/converter.rs`, `src/anthropic/handlers.rs`)
|
||||
- 新增 `ConversionError::EmptyMessageContent` 错误类型,在请求转换阶段验证消息内容不为空
|
||||
- 在 prefill 处理后验证最后一条消息内容有效性,支持字符串和数组两种 content 格式
|
||||
- 检测空字符串、空白字符、空数组等情况,避免向上游发送无效请求
|
||||
- 修复 `is_input_too_long_error` 错误地将 "Improperly formed request" 归类为上下文过长错误
|
||||
- 新增 `is_improperly_formed_request_error` 函数专门处理格式错误,返回准确的错误信息
|
||||
- 新增 3 个测试用例验证空消息内容检测功能
|
||||
- **图片文件大小压缩优化** (`src/image.rs`)
|
||||
- 新增基于文件大小的强制重新编码逻辑:即使图片尺寸符合要求,如果文件大小超过 200KB 也会重新编码降低质量
|
||||
- 修复小尺寸高质量图片(如 483x480 但 631KB)直接透传导致请求体过大的问题
|
||||
- 新增日志输出追踪大文件重新编码过程和压缩率
|
||||
|
||||
## [v1.0.19] - 2026-02-17
|
||||
|
||||
### Changed
|
||||
- **自适应压缩策略优化** (`src/anthropic/handlers.rs`)
|
||||
- 请求体大小校验改为以实际序列化后的总字节数为准,不再扣除图片 base64 字节(上游存在约 5MiB 的硬性请求体大小限制,图片也必须计入)
|
||||
- 压缩层级重排:当单条 user content 已超过阈值时优先截断超长消息(第三层),再移除历史消息(第四层),避免移除历史后仍无法降到阈值内
|
||||
- 新增 `has_any_tool_results_or_tools` / `has_any_tool_uses` 预检,跳过无效的 tool 阈值降低迭代
|
||||
- 历史消息移除改为批量 drain(单轮最多 16 条),提升大上下文场景的压缩效率
|
||||
- **请求体大小阈值默认上调至 4.5MiB** (`src/model/config.rs`, `config.example.json`)
|
||||
- `compression.maxRequestBodyBytes` 从 400KB 上调至 4,718,592 字节(4.5MiB),匹配上游实际限制
|
||||
|
||||
### Fixed
|
||||
- **cargo fmt 格式化** (`src/anthropic/converter.rs`, `src/image.rs`)
|
||||
|
||||
## [v1.0.18] - 2026-02-17
|
||||
|
||||
### Added
|
||||
- **GIF 动图抽帧采样与强制重编码** (`src/image.rs`, `src/anthropic/converter.rs`)
|
||||
- 新增 `process_gif_frames()` 函数,将 GIF 动图抽帧为多张静态 JPEG,避免动图 base64 体积巨大导致 upstream 400 错误
|
||||
- 采样策略:总帧数不超过 20 帧,每秒最多 5 帧,超长 GIF 自动降低采样频率均匀抽取
|
||||
- 新增 `process_image_to_format()` 函数,支持将任意图片强制重编码为指定格式
|
||||
- GIF 抽帧失败时多级回退:JPEG 重编码 → 静态 GIF 处理 → 原始数据透传
|
||||
- `process_image()` 对 GIF 格式强制重编码为静态帧,即使无需缩放也避免透传体积巨大的动图
|
||||
- `ImageProcessResult` 新增 `was_reencoded`、`original_bytes_len`、`final_bytes_len` 字段
|
||||
|
||||
### Changed
|
||||
- **请求体大小阈值默认上调** (`src/model/config.rs`, `config.example.json`)
|
||||
- 上游存在请求体大小硬限制(实测约 5MiB 左右会触发 400),默认将 `compression.maxRequestBodyBytes` 上调至 4.5MiB 预留安全余量
|
||||
- **日志分析脚本同步更新** (`tools/analyze_compression.py`, `tools/diagnose_improper_request.py`)
|
||||
- 修复 ANSI 序列污染解析,并增加“自适应二次压缩/本地超限拒绝”的统计输出
|
||||
|
||||
## [v1.0.17] - 2026-02-15
|
||||
|
||||
### Added
|
||||
- **自适应压缩第四层:超长用户消息内容截断** (`src/anthropic/compressor.rs`, `src/anthropic/handlers.rs`)
|
||||
- 新增 `compress_long_messages_pass()` 函数,截断超长的 User 消息 content(保留头部,尾部附加省略标记)
|
||||
- 在 `adaptive_shrink_request_body` 的三层策略之后增加第四层兜底,解决单条消息过大(如粘贴整个文件)导致自适应压缩空转的问题
|
||||
- 动态计算截断阈值:初始为最大消息字符数的 3/4,每轮递减 3/4,最低 8192 字符
|
||||
- 日志新增 `final_message_content_max_chars` 字段便于排查
|
||||
|
||||
## [v1.0.16] - 2026-02-15
|
||||
|
||||
### Fixed
|
||||
- **请求体日志仅在 upstream 报错时输出完整内容** (`src/anthropic/handlers.rs`)
|
||||
- 移除发送前的完整请求体 DEBUG 日志(`sensitive-logs` 模式下每次请求都输出几十 KB JSON),统一只输出字节大小
|
||||
- upstream 报错时在 `sensitive-logs` 模式下以 ERROR 级别输出完整请求体(截断 base64),用于诊断 400/502 等错误
|
||||
|
||||
## [v1.0.15] - 2026-02-15
|
||||
|
||||
### Added
|
||||
- **Opus 4.6 1M 上下文窗口支持** (`src/anthropic/types.rs`, `src/anthropic/handlers.rs`, `src/anthropic/stream.rs`)
|
||||
- 新增 `get_context_window_size()` 函数,Opus 4.6 返回 1,000,000 tokens,其他模型返回 200,000 tokens
|
||||
- 删除硬编码 `CONTEXT_WINDOW_SIZE` 常量,改用动态计算
|
||||
- `MAX_BUDGET_TOKENS` 从 24,576 提升到 128,000
|
||||
- `Model` 结构体新增 `context_length`、`max_completion_tokens`、`thinking` 字段
|
||||
- **Agentic 模型变体** (`src/anthropic/converter.rs`, `src/anthropic/handlers.rs`)
|
||||
- 新增 sonnet-agentic、opus-4.5-agentic、opus-4.6-agentic、haiku-agentic 四个模型变体
|
||||
- `map_model()` 自动剥离 `-agentic` 后缀再映射
|
||||
- Agentic 模型注入专用系统提示,引导自主工作模式
|
||||
- **Thinking level 后缀** (`src/anthropic/handlers.rs`)
|
||||
- 支持 `-thinking-minimal`(512)、`-thinking-low`(1024)、`-thinking-medium`(8192)、`-thinking-high`(24576)、`-thinking-xhigh`(32768) 后缀
|
||||
- **工具压缩** (`src/anthropic/tool_compression.rs` 新建)
|
||||
- 20KB 阈值两步压缩:简化 input_schema → 按比例截断 description(最短 50 字符)
|
||||
- **截断检测** (`src/anthropic/truncation.rs` 新建)
|
||||
- 4 种截断类型的启发式检测(空输入、无效 JSON、缺少字段、未闭合字符串)
|
||||
- 工具 JSON 解析失败时自动检测截断并生成软失败消息
|
||||
|
||||
### Changed
|
||||
- 工具调用仅含 tool_use 时占位符从 `" "` 改为 `"."`,提升语义清晰度
|
||||
|
||||
## [v1.0.14] - 2026-02-15
|
||||
|
||||
### Fixed
|
||||
- **sensitive-logs 模式下请求体日志截断** (`src/kiro/provider.rs`, `src/anthropic/handlers.rs`)
|
||||
- 400 错误日志中的 `request_body` 字段改用 `truncate_body_for_log()` 截断(保留头尾各 1200 字符),避免输出包含大量 base64 图片数据的完整请求体
|
||||
- 工具输入 JSON 解析失败日志中的 `request_body` 字段改用 `truncate_middle()` 截断
|
||||
- 新增 `KiroProvider::truncate_body_for_log()` 函数,正确处理 UTF-8 多字节字符边界
|
||||
|
||||
## [v1.0.13] - 2026-02-14
|
||||
|
||||
### Fixed
|
||||
- **请求体大小预检输出 image_bytes 归因信息** (`src/anthropic/handlers.rs`)
|
||||
- 新增 `total_image_bytes()` 函数,计算 KiroRequest 中所有图片 base64 数据的总字节数
|
||||
- 错误提示信息增加 image_bytes 和 non-image bytes 字段,便于排查请求体大小归因
|
||||
|
||||
## [v1.0.12] - 2026-02-14
|
||||
|
||||
### Fixed
|
||||
- **WebSearch 仅纯搜索请求走本地处理** (`src/anthropic/websearch.rs`, `src/anthropic/handlers.rs`)
|
||||
- 新增 `should_handle_websearch_request()` 精确判断:仅当 tool_choice 强制选择 web_search、tools 仅含 web_search 单工具、或用户消息包含 `Perform a web search for the query:` 前缀时,才路由到本地 WebSearch 处理
|
||||
- 混合工具场景(web_search + 其他工具)改为剔除 web_search 后转发 upstream,避免普通对话被误当成搜索查询
|
||||
- 新增 `strip_web_search_tools()` 从 tools 列表中移除 web_search 工具
|
||||
- 搜索查询提取增加空白归一化处理
|
||||
|
||||
## [v1.0.11] - 2026-02-14
|
||||
|
||||
### Added
|
||||
- **自适应二次压缩策略** (`src/anthropic/handlers.rs`)
|
||||
- 请求体超过 `max_request_body_bytes` 阈值时,自动迭代压缩:逐步降低 tool_result/tool_use_input 截断阈值,最后按轮移除最老历史消息
|
||||
- 最多迭代 32 轮,避免极端输入导致过长 CPU 消耗
|
||||
- `post_messages` 和 `post_messages_cc` 均支持自适应压缩
|
||||
- **压缩后 tool_use/tool_result 配对修复** (`src/anthropic/compressor.rs`)
|
||||
- 新增 `repair_tool_pairing_pass()`:历史截断后自动移除孤立的 tool_use 和 tool_result
|
||||
- 解决截断破坏跨消息 tool_use→tool_result 配对导致 upstream 返回 400 "Improperly formed request" 的问题
|
||||
- **stable 版 `floor_char_boundary` 工具函数** (`src/common/utf8.rs`)
|
||||
- 新增 `common::utf8` 模块,提供 stable Rust 下的 `floor_char_boundary()` 实现
|
||||
- 统一替换项目中散落的 `str::floor_char_boundary()` nightly 调用
|
||||
|
||||
### Fixed
|
||||
- **WebSearch 支持混合工具列表** (`src/anthropic/websearch.rs`)
|
||||
- `has_web_search_tool()` 改为只要 tools 中包含 web_search(按 name 或 type 判断)即走本地处理,不再要求 tools 仅有一个
|
||||
- `extract_search_query()` 改为取最后一条 user 消息,更符合多轮对话场景
|
||||
- 新增非流式(`stream: false`)响应支持,返回完整 JSON 而非 SSE 流
|
||||
|
||||
### Changed
|
||||
- **迁移 `floor_char_boundary` 调用到 `common::utf8` 模块** (`src/anthropic/compressor.rs`, `src/anthropic/stream.rs`, `src/admin/service.rs`, `src/kiro/token_manager.rs`, `src/kiro/provider.rs`)
|
||||
- 移除各文件中重复的 `floor_char_boundary` 内联实现,统一使用 `crate::common::utf8::floor_char_boundary`
|
||||
|
||||
## [v1.0.10] - 2026-02-12
|
||||
|
||||
### Fixed
|
||||
- **配额耗尽返回 429 而非 502** (`src/anthropic/handlers.rs`, `src/kiro/provider.rs`)
|
||||
- 所有凭据配额耗尽时返回 `429 Too Many Requests`(`rate_limit_error`),而非 `502 Bad Gateway`
|
||||
- 余额刷新时主动禁用低余额凭据(余额 < 1.0),402 分支同步清零余额缓存
|
||||
- **亲和性检查不再触发限流** (`src/kiro/token_manager.rs`)
|
||||
- 亲和性检查改用 `check_rate_limit` 只读探测,消除"检查本身消耗速率配额"的恶性循环
|
||||
- 亲和性分流日志提升至 info 级别并脱敏 user_id,便于生产监控热点凭据
|
||||
|
||||
### Added
|
||||
- **请求体大小预检** (`src/anthropic/handlers.rs`, `src/model/config.rs`)
|
||||
- 新增 `max_request_body_bytes` 配置项,序列化后拦截超大请求避免无效 upstream 往返
|
||||
|
||||
### Changed
|
||||
- **移除无意义的 max_tokens 调整逻辑** (`src/anthropic/handlers.rs`)
|
||||
- 删除 max_tokens 超限警告日志和调整逻辑,因为该值实际不传递给 Kiro upstream
|
||||
|
||||
## [v1.0.9] - 2026-02-12
|
||||
|
||||
### Fixed
|
||||
- **修复 upstream 合并丢失的功能** (`src/anthropic/stream.rs`)
|
||||
- 恢复 `stop_reason` 优先级逻辑:高优先级原因可覆盖低优先级(model_context_window_exceeded > max_tokens > tool_use > end_turn)
|
||||
- 注释掉重复的 `content_block_start` 日志,避免日志噪音
|
||||
- 修复 `contextUsageEvent` 日志格式(保留4位小数)
|
||||
- 移除冗余的 `find_char_boundary` 函数,改用标准库 `str::floor_char_boundary()`
|
||||
|
||||
## [v1.0.8] - 2026-02-12
|
||||
|
||||
### Added
|
||||
- **批量导入 token.json** (`src/admin/service.rs`, `src/admin/types.rs`)
|
||||
- 新增 `import_token_json` 接口,支持批量导入官方 token.json 格式凭据
|
||||
- 自动映射 provider/authMethod 字段,支持 dry-run 预览模式
|
||||
- 去重检测:通过 refreshToken 前缀匹配避免重复导入
|
||||
|
||||
- **缓存余额查询接口** (`src/admin/handlers.rs`, `src/admin/router.rs`)
|
||||
- 新增 `GET /admin/credentials/balances/cached` 端点
|
||||
- 返回所有凭据的缓存余额信息(含 TTL 和缓存时间)
|
||||
|
||||
### Changed
|
||||
- **用户亲和性支持** (`src/kiro/token_manager.rs`, `src/kiro/provider.rs`)
|
||||
- 新增 `UserAffinityManager`:用户与凭据绑定,保持会话连续性
|
||||
- `acquire_context_for_user` 方法支持按 user_id 获取绑定凭据
|
||||
- 亲和性过期时间可配置,默认 30 分钟
|
||||
|
||||
- **余额缓存动态 TTL** (`src/kiro/token_manager.rs`)
|
||||
- 基于使用频率动态调整缓存 TTL(高频用户更短 TTL)
|
||||
- 新增 `update_balance_cache`、`get_all_cached_balances` 方法
|
||||
- 缓存持久化到 `kiro_balance_cache.json`
|
||||
|
||||
- **请求压缩管道** (`src/anthropic/converter.rs`, `src/anthropic/middleware.rs`)
|
||||
- `AppState` 新增 `compression_config` 字段
|
||||
- `convert_request` 支持 `CompressionConfig` 参数
|
||||
- 图片压缩、空白压缩、上下文截断等功能集成
|
||||
|
||||
- **凭据级代理配置** (`src/kiro/provider.rs`)
|
||||
- KiroProvider 支持按凭据动态选择代理
|
||||
- 新增 `get_client_for_credential` 方法,缓存凭据级 client
|
||||
- API 请求和 MCP 请求均使用凭据的 `effective_proxy()`
|
||||
|
||||
- **API Region 分离** (`src/kiro/provider.rs`)
|
||||
- `base_url`、`mcp_url`、`base_domain` 使用 `effective_api_region()`
|
||||
- 支持 Token 刷新和 API 调用使用不同 region
|
||||
|
||||
- **handlers 传递 user_id** (`src/anthropic/handlers.rs`)
|
||||
- 从请求 metadata 提取 user_id 并传递给 provider
|
||||
- 支持用户亲和性功能
|
||||
|
||||
### Fixed
|
||||
- **修复 mask_user_id UTF-8 panic** (`src/anthropic/handlers.rs`)
|
||||
- 使用 `chars()` 按字符而非字节切片,避免多字节字符导致 panic
|
||||
|
||||
## [v1.0.7] - 2026-02-10
|
||||
|
||||
### Added
|
||||
- **图片 Token 估算与压缩模块** (`src/image.rs`)
|
||||
- 新增 `estimate_image_tokens()`: 从 base64 数据解析图片尺寸并估算 token 数
|
||||
- 新增 `process_image()`: 根据配置对大图进行缩放压缩
|
||||
- 实现 Anthropic 官方公式: `tokens = (width × height) / 750`
|
||||
- 支持 JPEG/PNG/GIF/WebP 格式
|
||||
|
||||
### Changed
|
||||
- **Token 计算支持图片** (`src/token.rs`)
|
||||
- `count_all_tokens_local()` 现在处理 `type: "image"` 的 ContentBlock
|
||||
- 调用 `estimate_image_tokens()` 计算图片 token,解决之前图片 token 被计为 0 的问题
|
||||
- **协议转换支持图片压缩** (`src/anthropic/converter.rs`)
|
||||
- `process_message_content()` 新增图片压缩处理,根据配置自动缩放超限图片
|
||||
- 新增 `count_images_in_request()` 统计请求中图片总数,用于判断多图模式
|
||||
- 缩放后记录日志: `图片已缩放: (原始尺寸) -> (缩放后尺寸), tokens: xxx`
|
||||
- **压缩配置新增图片参数** (`src/model/config.rs`)
|
||||
- `image_max_long_edge`: 长边最大像素,默认 1568(Anthropic 推荐值)
|
||||
- `image_max_pixels_single`: 单张图片最大总像素,默认 1,150,000(约 1600 tokens)
|
||||
- `image_max_pixels_multi`: 多图模式下单张最大像素,默认 4,000,000(2000×2000)
|
||||
- `image_multi_threshold`: 触发多图限制的图片数量阈值,默认 20
|
||||
|
||||
### Dependencies
|
||||
- 新增 `image` crate (0.25): 图片处理(支持 JPEG/PNG/GIF/WebP)
|
||||
- 新增 `base64` crate (0.22): Base64 编解码
|
||||
|
||||
## [v1.0.6] - 2026-02-10
|
||||
|
||||
### Changed
|
||||
- **压缩统计日志改用字节单位** (`src/anthropic/handlers.rs`)
|
||||
- 移除不准确的 token 估算(`compressed_input_tokens`、`tokens_saved`),改为直接输出字节数
|
||||
- 字段重命名:`whitespace_saved` → `whitespace_bytes_saved` 等,明确单位语义
|
||||
- 注释更新:说明字节统计用于排查 upstream 请求体大小限制
|
||||
|
||||
### Added
|
||||
- **日志脱敏工具模块** (`src/common/redact.rs`)
|
||||
- `redact_opt_string`: Option<String> 脱敏为存在性表示
|
||||
- `mask_email`: 邮箱脱敏(保留首字符)
|
||||
- `mask_aws_account_id_in_arn`: AWS ARN 中 account id 脱敏
|
||||
- `mask_url_userinfo`: URL userinfo 脱敏
|
||||
- `mask_user_agent_machine_id`: User-Agent 中 machine_id 脱敏
|
||||
|
||||
## [v1.0.5] - 2026-02-09
|
||||
|
||||
### Changed
|
||||
- **请求日志输出压缩前后 token 估算** (`src/anthropic/converter.rs`, `src/anthropic/handlers.rs`)
|
||||
- `ConversionResult` 新增 `compression_stats` 字段,将压缩统计从 converter 内部返回给调用方
|
||||
- `post_messages` / `post_messages_cc` 在 `convert_request()` 之前估算 input tokens,`Received` 日志追加 `estimated_input_tokens`
|
||||
- 压缩完成后输出 token 对比日志:`estimated_input_tokens`、`compressed_input_tokens`、`tokens_saved` 及各项压缩明细
|
||||
- WebSearch 分支复用已有估算值,消除重复的 `count_all_tokens` 调用
|
||||
- **`count_all_tokens` 改为接受借用** (`src/token.rs`)
|
||||
- 参数从按值传递改为引用(`&str`、`&[Message]`、`&Option<Vec<T>>`),消除 handlers 中的深拷贝开销
|
||||
- **历史截断统计增加字节数** (`src/anthropic/compressor.rs`)
|
||||
- `CompressionStats` 新增 `history_bytes_saved` 字段,`total_saved()` 包含历史截断字节数
|
||||
- `compress_history_pass` 返回 `(turns_removed, bytes_saved)` 元组,token 估算准确计入历史截断
|
||||
|
||||
## [v1.0.4] - 2026-02-09
|
||||
|
||||
### Changed
|
||||
- **Docker 构建优化**: 引入 `cargo-chef` 实现依赖层缓存,大幅加速重复构建
|
||||
- 新增 `planner` 阶段生成依赖 recipe,`builder` 阶段先编译依赖再编译源码
|
||||
- Docker 层缓存使得仅源码变更时无需重新编译所有依赖
|
||||
- **GitHub Actions Docker 构建缓存**: 启用 GHA 缓存(`cache-from/cache-to: type=gha`)
|
||||
- **Cargo.toml 优化**:
|
||||
- `lto` 从 `true`(fat LTO)改为 `"thin"`,加快编译速度同时保持较好的优化效果
|
||||
- `tokio` features 从 `full` 精简为实际使用的 5 个 feature(`rt-multi-thread`, `macros`, `net`, `time`, `sync`),减小编译体积
|
||||
|
||||
## [v1.0.3] - 2026-02-09
|
||||
|
||||
### Fixed
|
||||
- **opus4-6 上下文超限错误识别** (`src/anthropic/handlers.rs`)
|
||||
- 将 `Improperly formed request` 纳入输入过长错误检测,返回 Claude Code 可识别的 `400 Input is too long` 而非 `502 Bad Gateway`
|
||||
- **Opus 4.6 模型 ID 移除时间日期** (`src/anthropic/handlers.rs`)
|
||||
- `claude-opus-4-6-20260206` → `claude-opus-4-6`
|
||||
- **post_messages 借用冲突编译错误** (`src/anthropic/handlers.rs`)
|
||||
- 将 `override_thinking_from_model_name` 调用移至 `user_id` 提取之前,修复 E0502 借用冲突
|
||||
|
||||
### Added
|
||||
- **模型 "thinking" 后缀支持** (`src/anthropic/handlers.rs`, `src/anthropic/converter.rs`)
|
||||
- 模型名含 `-thinking` 后缀时自动覆写 thinking 配置(opus4.6 用 adaptive + high effort,其他用 enabled)
|
||||
- 模型列表新增 sonnet/opus4.5/opus4.6/haiku 的 thinking 变体
|
||||
|
||||
### Docs
|
||||
- **README.md 补充输入压缩配置文档**
|
||||
- 新增"输入压缩"章节,说明 5 层压缩管道机制和 10 个可配置参数
|
||||
|
||||
## [v1.0.2] - 2026-02-09
|
||||
|
||||
### Fixed
|
||||
- **opus4-6 上下文超限错误识别** (`src/anthropic/handlers.rs`)
|
||||
- 将 `Improperly formed request` 纳入输入过长错误检测,返回 Claude Code 可识别的 `400 Input is too long` 而非 `502 Bad Gateway`
|
||||
- **Opus 4.6 模型 ID 移除时间日期** (`src/anthropic/handlers.rs`)
|
||||
- `claude-opus-4-6-20260206` → `claude-opus-4-6`
|
||||
- **post_messages 借用冲突编译错误** (`src/anthropic/handlers.rs`)
|
||||
- 将 `override_thinking_from_model_name` 调用移至 `user_id` 提取之前,修复 E0502 借用冲突
|
||||
|
||||
### Added
|
||||
- **模型 "thinking" 后缀支持** (`src/anthropic/handlers.rs`, `src/anthropic/converter.rs`)
|
||||
- 模型名含 `-thinking` 后缀时自动覆写 thinking 配置(opus4.6 用 adaptive + high effort,其他用 enabled)
|
||||
- 模型列表新增 sonnet/opus4.5/opus4.6/haiku 的 thinking 变体
|
||||
|
||||
### Docs
|
||||
- **README.md 补充输入压缩配置文档**
|
||||
- 新增"输入压缩"章节,说明 5 层压缩管道机制和 10 个可配置参数
|
||||
|
||||
## [v1.0.1] - 2026-02-08
|
||||
|
||||
### Fixed
|
||||
- **历史截断字符统计口径修正** (`src/anthropic/compressor.rs`)
|
||||
- `max_history_chars` 从按字节 `.len()` 改为按字符 `.chars().count()`,与配置语义一致
|
||||
- **remove_thinking_blocks 不再全局 trim** (`src/anthropic/compressor.rs`)
|
||||
- 移除末尾 `.trim()`,避免意外吞掉原始内容的首尾空白
|
||||
- **token.json 导入注释/逻辑统一** (`src/admin/service.rs`)
|
||||
- 更新注释:`builder-id → idc`(与实际映射一致)
|
||||
- 删除 `auth_method == "builder-id"` 死分支
|
||||
- **Admin UI 验活开关 added=0 卡住修复** (`admin-ui/src/components/import-token-json-dialog.tsx`)
|
||||
- `enableVerify` 开启但无新增凭据时,直接跳转 result 步骤而非卡在 preview
|
||||
- **工具描述截断 max_description_chars=0 语义修正** (`src/anthropic/converter.rs`)
|
||||
- `0` 现在表示"不截断",而非截断为空字符串
|
||||
|
||||
### Added
|
||||
- **输入压缩管道** (`src/anthropic/compressor.rs`)
|
||||
- 新增 5 层压缩管道,规避 Kiro upstream 请求体大小限制
|
||||
- 空白压缩:连续空行(3+)→2行,行尾空格移除,保留行首缩进
|
||||
- thinking 块处理:支持 discard/truncate/keep 三种策略
|
||||
- tool_result 智能截断:按行截断保留头尾,行数不足时回退字符级截断
|
||||
- tool_use input 截断:递归截断 JSON 值中的大字符串
|
||||
- 历史截断:保留系统消息对,按轮数/字符数从前往后成对移除
|
||||
- 12 个单元测试覆盖所有压缩层和边界条件
|
||||
- **CompressionConfig 配置结构体** (`src/model/config.rs`)
|
||||
- 新增 `compression` 配置字段,支持通过 JSON 配置文件调整参数
|
||||
- 10 个可配置参数:总开关、空白压缩、thinking 策略、各截断阈值、历史限制
|
||||
- 工具描述截断阈值从硬编码 10000 改为可配置(默认 4000)
|
||||
- **合并 upstream 新功能**: 从 upstream/master 拉取并融合大量新特性
|
||||
- **负载均衡模式** (`src/model/config.rs`, `src/kiro/token_manager.rs`, `src/admin/`)
|
||||
- 新增 `loadBalancingMode` 配置项,支持 `priority`(默认)和 `balanced`(Least-Used)两种模式
|
||||
- Admin API 新增 `GET/PUT /config/load-balancing` 端点
|
||||
- 前端新增负载均衡模式切换开关
|
||||
- **凭据统计与持久化** (`src/kiro/token_manager.rs`)
|
||||
- 新增 `success_count`、`last_used_at` 字段,跟踪每个凭据的调用统计
|
||||
- 新增 `save_stats_debounced` 防抖持久化机制,`Drop` 时自动保存
|
||||
- 新增 `refreshTokenHash` 字段用于前端重复检测
|
||||
- **前端批量操作** (`admin-ui/src/components/dashboard.tsx`)
|
||||
- 批量导入对话框 (`BatchImportDialog`)
|
||||
- 批量验活对话框 (`BatchVerifyDialog`)
|
||||
- 分页控件、批量选择/删除/恢复/验活功能
|
||||
- 凭据卡片新增 Checkbox 选择、email 显示、订阅等级、成功次数、剩余用量等信息
|
||||
- **配置文件路径管理** (`src/model/config.rs`)
|
||||
- `Config` 新增 `config_path` 字段和 `load()`/`save()` 方法,支持配置回写
|
||||
- **前端依赖**: 新增 `@radix-ui/react-checkbox` 组件
|
||||
|
||||
### Changed
|
||||
- `convert_request()` 签名新增 `&CompressionConfig` 参数 (`src/anthropic/converter.rs`)
|
||||
- `convert_tools()` 描述截断阈值参数化 (`src/anthropic/converter.rs`)
|
||||
- `AppState` 新增 `compression_config` 字段 (`src/anthropic/middleware.rs`)
|
||||
- `create_router_with_provider()` 新增 `CompressionConfig` 参数 (`src/anthropic/router.rs`)
|
||||
- 重构 README.md 配置文档,提升新用户上手体验
|
||||
- 明确配置文件默认路径:当前工作目录(或通过 `-c`/`--config` 和 `--credentials` 参数指定)
|
||||
- 添加 JSON 注释警告:移除所有带 `//` 注释的示例,提供可直接复制的配置
|
||||
- 修正字段必填性:仅 `apiKey` 为必填,其他字段均有默认值
|
||||
- 新增命令行参数说明表格(`-c`, `--credentials`, `-h`, `-V`)
|
||||
- 补充遗漏的 `credentialRpm` 字段说明(凭据级 RPM 限流)
|
||||
- 使用表格形式展示配置字段,标注必填/可选和默认值
|
||||
- 优化 debug 日志中请求体的输出长度 (`src/anthropic/handlers.rs`)
|
||||
- 新增 `truncate_middle()` 函数:截断字符串中间部分,保留头尾各 1200 字符
|
||||
- 正确处理 UTF-8 多字节字符边界,不会截断中文
|
||||
- 仅在启用 `sensitive-logs` feature 时生效,减少日志噪音
|
||||
|
||||
### Fixed
|
||||
- **[P0] API Key 日志泄露修复** (`src/main.rs`)
|
||||
- info 级别不再打印 API Key 前半段,仅显示末 4 位和长度
|
||||
- 完整前缀仅在 `sensitive-logs` feature 的 debug 级别输出
|
||||
- **[P2] 占位工具大小写变体重复插入** (`src/anthropic/converter.rs`)
|
||||
- `collect_history_tool_names` 改为小写去重,避免 `read`/`Read` 等变体重复
|
||||
- 占位工具 push 后同步更新 `existing_tool_names` 集合
|
||||
- **[P1] 统计与缓存写盘非原子操作** (`src/kiro/token_manager.rs`, `src/admin/service.rs`)
|
||||
- 统计数据和余额缓存改为临时文件 + 原子重命名,防止写入中断导致文件损坏
|
||||
- **[P1] stop_reason 覆盖策略可能丢失信息** (`src/anthropic/stream.rs`)
|
||||
- `set_stop_reason()` 改为基于优先级覆盖,高优先级原因可覆盖低优先级原因
|
||||
- **[P2] snapshot 重复计算 SHA-256** (`src/kiro/token_manager.rs`)
|
||||
- `CredentialEntry` 新增 `refresh_token_hash` 缓存字段
|
||||
- Token 刷新时自动更新哈希,`snapshot()` 优先使用缓存避免重复计算
|
||||
- **Clippy 警告修复** (`src/model/config.rs`)
|
||||
- 修复 `field_reassign_with_default` 警告,改用结构体初始化语法
|
||||
- **[P2] Assistant Prefill 静默丢弃** (`src/anthropic/converter.rs`, `src/anthropic/handlers.rs`)
|
||||
- 末尾 `assistant` 消息(prefill 场景)不再返回 400 错误,改为静默丢弃并回退到最后一条 `user` 消息
|
||||
- Claude 4.x 已弃用 assistant prefill,Kiro API 也不支持,转换器在入口处截断消息列表
|
||||
- 移除 `InvalidLastMessageRole` 错误变体,`build_history` 接受预处理后的消息切片
|
||||
- **[P2] 凭据回写原子性** (`src/kiro/token_manager.rs`)
|
||||
- `persist_credentials` 改为临时文件 + `rename` 原子替换
|
||||
- 新增 `resolve_symlink_target` 辅助函数:优先 `canonicalize`,失败时用 `read_link` 解析 symlink
|
||||
- 保留原文件权限,防止 umask 导致凭据文件权限放宽
|
||||
- Windows 兼容:`rename` 前先删除已存在的目标文件
|
||||
- 避免进程崩溃或并发调用导致凭据文件损坏
|
||||
- 限制 `max_tokens` 最大值为 32000(Kiro upstream 限制)
|
||||
- 当用户设置超出限制的值时自动调整为 32000
|
||||
- 记录 WARN 级别日志,包含原始值和调整后的值
|
||||
- 涉及文件:`src/anthropic/handlers.rs`
|
||||
- 工具输入 JSON 解析失败时的日志输出改为受 `sensitive-logs` feature 控制
|
||||
- 默认仅输出 `buffer_len` 和 `request_body_bytes`(长度信息)
|
||||
- 启用 `--features sensitive-logs` 时输出完整 `buffer` 和 `request_body`
|
||||
- 涉及文件:`src/anthropic/handlers.rs`
|
||||
- 修复 Kiro upstream 请求兼容性问题
|
||||
- 空 content(仅 tool_result/image)时使用占位符避免 400
|
||||
- 规范化工具 JSON Schema、补全空 description
|
||||
- 禁用 reqwest 系统代理探测(仅支持显式 `config.proxy_url`)
|
||||
- 新增离线诊断脚本:`tools/diagnose_improper_request.py`
|
||||
- 涉及文件:`src/anthropic/converter.rs`、`src/http_client.rs`、`tools/diagnose_improper_request.py`、`.gitignore`
|
||||
- 优化 400 Bad Request "输入过长" 错误的日志输出 (`src/kiro/provider.rs`)
|
||||
- 对于 `CONTENT_LENGTH_EXCEEDS_THRESHOLD` / `Input is too long` 错误,不再输出完整请求体(太占空间且无调试价值)
|
||||
- 改为记录 `request_body_bytes`(字节数)和 `estimated_input_tokens`(估算 token 数)
|
||||
- 新增 `estimate_tokens()` 函数:基于 CJK/非 CJK 字符比例估算 token 数量
|
||||
- CJK 字符(中/日/韩): token 数 = 字符数 / 1.5
|
||||
- 其他字符(英文等): token 数 = 字符数 / 3.5
|
||||
- 新增 `is_input_too_long()` 和 `is_cjk_char()` 辅助函数
|
||||
|
||||
- 新增多维度设备指纹系统 (`src/kiro/fingerprint.rs`)
|
||||
- 每个凭据生成独立的确定性指纹,模拟真实 Kiro IDE 客户端
|
||||
- 支持 10+ 维度设备信息:SDK 版本、Kiro 版本、Node.js 版本、操作系统、屏幕分辨率、CPU 核心数、时区等
|
||||
- 提供 `user_agent()` 和 `x_amz_user_agent()` 方法构建请求头
|
||||
- 参考 CLIProxyAPIPlus 实现,降低被检测风险
|
||||
|
||||
- 新增精细化速率限制系统 (`src/kiro/rate_limiter.rs`)
|
||||
- 每日请求限制(默认 500 次/天)
|
||||
- 请求间隔控制(1-2 秒 + 30% 抖动)
|
||||
- 指数退避策略(30s → 5min,倍数 1.5)
|
||||
- 暂停检测(关键词匹配:suspended, banned, quota exceeded 等)
|
||||
|
||||
- 新增独立冷却管理模块 (`src/kiro/cooldown.rs`)
|
||||
- 分类冷却原因(7 种类型:速率限制、账户暂停、配额耗尽、Token 刷新失败等)
|
||||
- 差异化冷却时长:短冷却(1-5 分钟)vs 长冷却(1-24 小时)
|
||||
- 递增冷却机制(连续触发时延长冷却时间)
|
||||
- 自动清理过期冷却
|
||||
|
||||
- 新增后台 Token 刷新模块 (`src/kiro/background_refresh.rs`)
|
||||
- 独立后台任务定期检查即将过期的 Token
|
||||
- 支持批量并发刷新(信号量控制)
|
||||
- 可配置检查间隔、批处理大小、并发数
|
||||
- 优雅关闭机制
|
||||
|
||||
- `MultiTokenManager` 新增增强方法
|
||||
- `get_fingerprint()`: 获取凭据的设备指纹
|
||||
- `is_credential_available()`: 综合检查凭据可用性(未禁用、未冷却、未超速率限制)
|
||||
- `set_credential_cooldown()` / `clear_credential_cooldown()`: 冷却管理
|
||||
- `get_expiring_credential_ids()`: 获取即将过期的凭据列表
|
||||
- `start_background_refresh()`: 启动后台 Token 刷新任务
|
||||
- `refresh_token_for_credential()`: 带优雅降级的 Token 刷新
|
||||
- `record_api_success()` / `record_api_failure()`: 更新速率限制器状态
|
||||
- `CredentialEntry` 结构体新增 `fingerprint` 字段,每个凭据独立生成设备指纹
|
||||
|
||||
- 修复 IDC 凭据 `fetch_profile_arn` 在某些 region 返回 `UnknownOperationException` 的问题
|
||||
- 新增 `ListAvailableCustomizations` API 作为 `ListProfiles` 的回退方案
|
||||
- 支持多 region 尝试:先尝试用户配置的 region,失败则回退到 `us-east-1`
|
||||
- 涉及文件:`src/kiro/token_manager.rs`
|
||||
|
||||
- 修复 `start_background_refresh` 后台刷新器生命周期问题(Codex Review P1)
|
||||
- 问题:`refresher` 作为局部变量在函数返回后被 drop,导致后台任务立即停止
|
||||
- 解决:方法现在返回 `Arc<BackgroundRefresher>`,调用方需保持引用以维持任务运行
|
||||
- 涉及文件:`src/kiro/token_manager.rs`
|
||||
|
||||
- 修复 `calculate_backoff` 退避时间可能超过配置上限的问题(Codex Review P2)
|
||||
- 问题:添加抖动后未再次进行上限约束,可能导致实际等待时间超过 `backoff_max_ms`
|
||||
- 解决:在添加抖动后再进行 `.min(max)` 约束
|
||||
- 涉及文件:`src/kiro/rate_limiter.rs`
|
||||
|
||||
- 改进 `persist_credentials` 并发写入安全性(Codex Review P1)
|
||||
- 问题:在锁外执行文件写入可能导致并发写入时旧快照覆盖新数据
|
||||
- 解决:在持有 entries 锁的情况下完成序列化,确保快照一致性
|
||||
- 涉及文件:`src/kiro/token_manager.rs`
|
||||
- 修复 IDC 凭据返回 403 "The bearer token included in the request is invalid" 的问题
|
||||
- 根本原因:`profile_arn` 只从第一个凭据获取并存储在全局 `AppState` 中,当使用 IDC 凭据时,Bearer Token 来自 IDC 凭据,但 `profile_arn` 来自第一个凭据(可能是 Social 类型),导致 Token 和 profile_arn 不匹配
|
||||
- 解决方案 1:在 `call_api_with_retry` 中动态注入当前凭据的 `profile_arn`,确保 Token 和 profile_arn 始终匹配
|
||||
- 解决方案 2:IDC Token 刷新后自动调用 `ListProfiles` API 获取 `profileArn`(IDC 的 OIDC 刷新不返回此字段)
|
||||
- 新增 `inject_profile_arn()` 辅助方法,解析请求体 JSON 并覆盖 `profileArn` 字段
|
||||
- 新增 `fetch_profile_arn()` 方法,通过 CodeWhisperer ListProfiles API 获取 profileArn
|
||||
- 涉及文件:`src/kiro/provider.rs`, `src/kiro/token_manager.rs`
|
||||
- 新增批量导入 token.json 功能
|
||||
- 后端:新增 `POST /api/admin/credentials/import-token-json` 端点
|
||||
- 支持解析官方 token.json 格式(含 `provider`、`refreshToken`、`clientId`、`clientSecret` 等字段)
|
||||
- 按 `provider` 字段自动映射 `authMethod`(BuilderId → idc, IdC → idc, Social → social)
|
||||
- 支持 dry-run 预览模式,返回详细的导入结果(成功/跳过/无效)
|
||||
- 通过 refreshToken 前缀匹配自动去重,避免重复导入
|
||||
- 前端:新增"导入 token.json"对话框组件
|
||||
- 支持拖放上传 JSON 文件或直接粘贴 JSON 内容
|
||||
- 三步流程:输入 → 预览 → 结果
|
||||
- 涉及文件:
|
||||
- `src/admin/types.rs`(新增 `TokenJsonItem`、`ImportTokenJsonRequest`、`ImportTokenJsonResponse` 等类型)
|
||||
- `src/admin/service.rs`(新增 `import_token_json()` 方法)
|
||||
- `src/admin/handlers.rs`(新增 `import_token_json` handler)
|
||||
- `src/admin/router.rs`(添加路由)
|
||||
- `src/kiro/token_manager.rs`(新增 `has_refresh_token_prefix()` 方法)
|
||||
- `admin-ui/src/types/api.ts`(新增导入相关类型)
|
||||
- `admin-ui/src/api/credentials.ts`(新增 `importTokenJson()` 函数)
|
||||
- `admin-ui/src/hooks/use-credentials.ts`(新增 `useImportTokenJson()` hook)
|
||||
- `admin-ui/src/components/import-token-json-dialog.tsx`(新建)
|
||||
- `admin-ui/src/components/dashboard.tsx`(添加导入按钮)
|
||||
|
||||
- 修复字符串切片在多字节字符中间切割导致 panic 的风险(DoS 漏洞)
|
||||
- `generate_fingerprint()` 和 `has_refresh_token_prefix()` 使用 `floor_char_boundary()` 安全截断
|
||||
- 涉及文件:`src/admin/service.rs`, `src/kiro/token_manager.rs`
|
||||
- 修复日志截断在多字节字符中间切割导致 panic 的问题
|
||||
- `truncate_for_log()` 使用 `floor_char_boundary()` 安全截断 UTF-8 字符串
|
||||
- 删除 `stream.rs` 中冗余的 `find_char_boundary()` 函数,直接使用标准库方法
|
||||
- 涉及文件:`src/kiro/provider.rs`, `src/anthropic/stream.rs`
|
||||
- 移除历史消息中孤立的 tool_use(无对应 tool_result)
|
||||
- Kiro API 要求 tool_use 必须有配对的 tool_result,否则返回 400 Bad Request
|
||||
- 新增 `remove_orphaned_tool_uses()` 函数清理孤立的 tool_use
|
||||
- 涉及文件:`src/anthropic/converter.rs`
|
||||
- 修复 `/cc/v1/messages` 缓冲流 ping 定时器首次立即触发的问题
|
||||
- 将 `interval()` 改为 `interval_at(Instant::now() + ping_period, ping_period)`
|
||||
- 现在首个 ping 会在 25 秒后触发,与 `/v1/messages` 行为一致
|
||||
- 涉及文件:`src/anthropic/handlers.rs`
|
||||
- 修复 Clippy `collapsible_if` 警告
|
||||
- 使用 let-chains 语法合并嵌套 if 语句
|
||||
- 涉及文件:`src/anthropic/stream.rs`
|
||||
|
||||
- 增强 400 Bad Request 错误日志,记录完整请求信息
|
||||
- 移除请求体截断限制,记录完整的 `request_body`
|
||||
- 新增 `request_url` 和 `request_headers` 字段
|
||||
- 新增 `format_headers_for_log()` 辅助函数,对 Authorization 头进行脱敏处理
|
||||
- 删除不再使用的 `truncate_for_log()` 函数(YAGNI 原则)
|
||||
- 涉及文件:`src/kiro/provider.rs`
|
||||
- 改进凭据选择算法:同优先级内实现负载均衡
|
||||
- 第一优先级:使用次数最少
|
||||
- 第二优先级:余额最多(使用次数相同时)
|
||||
- 第三优先级:轮询选择(使用次数和余额完全相同时,避免总选第一个)
|
||||
- 新增 `selection_rr` 原子计数器用于轮询抖动
|
||||
- 新增 `select_best_candidate_id()` 方法实现三级排序逻辑
|
||||
- 涉及文件:`src/kiro/token_manager.rs`
|
||||
|
||||
- 修复测试代码使用 `serde_json::json!` 构造 Tool 对象导致的类型不匹配问题
|
||||
- 改用 `Tool` 结构体直接构造,确保类型安全
|
||||
- 涉及文件:`src/anthropic/websearch.rs`
|
||||
- 修复 `select_best_candidate_id()` 中 NaN 余额处理问题
|
||||
- 在评分阶段将 NaN/Infinity 余额归一化为 0.0
|
||||
- 避免 NaN 被 `total_cmp` 视为最大值导致错误的凭据选择
|
||||
- 避免 NaN 导致 `scored` 被完全过滤后除零 panic
|
||||
- 涉及文件:`src/kiro/token_manager.rs`
|
||||
- 新增 `system` 字段格式兼容性支持(`src/anthropic/types.rs`)
|
||||
- 支持字符串格式:`"system": "You are a helpful assistant"`(new-api 等网关添加的系统提示词)
|
||||
- 支持数组格式:`"system": [{"type": "text", "text": "..."}]`(Claude Code 原生格式)
|
||||
- 自动将字符串格式转换为单元素数组,保持内部处理一致性
|
||||
- 新增 6 个单元测试验证格式兼容性
|
||||
- 新增请求体大小限制:50MB(`DefaultBodyLimit::max(50 * 1024 * 1024)`)
|
||||
- 涉及文件:`src/anthropic/router.rs`
|
||||
- 调整全局禁用恢复时间:`GLOBAL_DISABLE_RECOVERY_MINUTES` 从 10 分钟降至 5 分钟
|
||||
- 加快模型暂时不可用后的自动恢复速度
|
||||
- 调整总重试次数硬上限:`MAX_TOTAL_RETRIES` 从 5 降至 3
|
||||
- 进一步减少无效重试开销,加快故障转移速度
|
||||
- 余额初始化改为顺序查询,每次间隔 0.5 秒避免触发限流
|
||||
- 从并发查询改为顺序查询(`initialize_balances()`)
|
||||
- 移除 30 秒整体超时机制
|
||||
- 涉及文件:`src/kiro/token_manager.rs`
|
||||
|
||||
- 修复 assistant 消息仅包含 tool_use 时 content 为空导致 Kiro API 报错的问题
|
||||
- 当 text_content 为空且存在 tool_uses 时,使用 "OK" 作为占位符
|
||||
- 涉及文件:`src/anthropic/converter.rs`
|
||||
- 修复 `MODEL_TEMPORARILY_UNAVAILABLE` 错误检测逻辑未实际调用的问题
|
||||
- 在 `call_mcp()` 和 `call_api()` 中添加错误检测和熔断触发逻辑
|
||||
- 移除 `report_model_unavailable()` 和 `disable_all_credentials()` 的 `#[allow(dead_code)]` 标记
|
||||
- 现在当检测到该错误时会正确触发全局熔断机制
|
||||
|
||||
- 新增 WebSearch 工具支持(`src/anthropic/websearch.rs`)
|
||||
- 实现 Anthropic WebSearch 请求到 Kiro MCP 的转换
|
||||
- 支持 SSE 流式响应,生成完整的搜索结果事件序列
|
||||
- 自动检测纯 WebSearch 请求(tools 仅包含 web_search)并路由到专用处理器
|
||||
- 新增 MCP API 调用支持(`src/kiro/provider.rs`)
|
||||
- 新增 `call_mcp()` 方法,支持 WebSearch 等工具调用
|
||||
- 新增 `mcp_url()` 和 `build_mcp_headers()` 方法
|
||||
- 完整的重试和故障转移逻辑
|
||||
- 新增凭据级 `region` 字段,用于 OIDC token 刷新时指定 endpoint 区域
|
||||
- 未配置时回退到 config.json 的全局 region
|
||||
- API 调用仍使用 config.json 的 region
|
||||
- 新增凭据级 `machineId` 字段,支持每个凭据使用独立的机器码
|
||||
- 支持 64 字符十六进制和 UUID 格式(自动标准化)
|
||||
- 未配置时回退到 config.json 的 machineId,都未配置时由 refreshToken 派生
|
||||
- 启动时自动补全并持久化到配置文件
|
||||
- 新增 GitHub Actions Docker 构建工作流(`.github/workflows/docker-build.yaml`)
|
||||
- 支持 linux/amd64 和 linux/arm64 双架构
|
||||
- 推送到 GitHub Container Registry
|
||||
- 版本号升级至 2026.1.5
|
||||
- TLS 库从 native-tls 切换至 rustls(reqwest 依赖调整)
|
||||
- `authMethod` 自动推断:未指定时根据是否有 clientId/clientSecret 自动判断为 idc 或 social
|
||||
- 移除 web_search/websearch 工具过滤(`is_unsupported_tool` 现在返回 false)
|
||||
|
||||
- 修复 machineId 格式兼容性问题,支持 UUID 格式自动转换为 64 字符十六进制
|
||||
### Removed
|
||||
- 移除 `current_id` 概念(后端和前端)
|
||||
- 后端:移除 `MultiTokenManager.current_id` 字段和相关方法(`switch_to_next`、`select_highest_priority`、`select_by_balance`、`credentials`)
|
||||
- 后端:移除 `ManagerSnapshot.current_id` 字段
|
||||
- 后端:移除 `CredentialStatusItem.is_current` 字段
|
||||
- 前端:移除 `CredentialsStatusResponse.currentId` 和 `CredentialStatusItem.isCurrent`
|
||||
- 原因:多用户并发访问时,"当前凭据"概念无意义,凭据选择由 `acquire_context_for_user()` 动态决定
|
||||
|
||||
- 新增启动时余额初始化功能
|
||||
- `initialize_balances()`: 启动时并发查询所有凭据余额并更新缓存
|
||||
- 整体超时 30 秒,避免阻塞启动流程
|
||||
- 初始化失败或超时时输出警告日志
|
||||
- 改进凭据选择算法:从单一"使用次数最少"改为两级排序
|
||||
- 第一优先级:使用次数最少
|
||||
- 第二优先级:余额最多(使用次数相同时)
|
||||
- 未初始化余额的凭据会被降级处理,避免被优先选中
|
||||
- 移除前端"当前活跃"凭据展示
|
||||
- 前端:移除凭据卡片的"当前"高亮和 Badge
|
||||
- 前端:移除 Dashboard 中的"当前活跃"统计卡片
|
||||
- 统计卡片布局从 3 列调整为 2 列
|
||||
|
||||
- 新增 `sensitive-logs` feature flag,显式启用才允许打印潜在敏感信息(仅用于排障)
|
||||
- 默认关闭:Kiro 请求体只输出长度,凭证只输出摘要信息
|
||||
- 启用方式:`cargo build --features sensitive-logs`
|
||||
|
||||
- 修复 SSE 流 ping 保活首次立即触发的问题
|
||||
- 使用 `interval_at(Instant::now() + ping_period, ping_period)` 延迟首次触发
|
||||
- 避免连接建立后立即发送无意义的 ping 事件
|
||||
- 改进服务启动错误处理
|
||||
- 绑定监听地址失败时输出错误日志并退出(exit code 1)
|
||||
- HTTP 服务异常退出时输出错误日志并退出(exit code 1)
|
||||
|
||||
- 修复合并 upstream 后 `CredentialEntry` 结构体字段缺失导致的编译错误
|
||||
- 添加 `disable_reason: Option<DisableReason>` 字段(公共 API 展示用)
|
||||
- 添加 `auto_heal_reason: Option<AutoHealReason>` 字段(内部自愈逻辑用)
|
||||
- 修复禁用原因字段不同步问题
|
||||
- `report_failure()`: 禁用时同步设置两个字段
|
||||
- `set_disabled()`: 启用/禁用时同步设置/清除两个字段
|
||||
- `reset_and_enable()`: 重置时同步清除两个字段
|
||||
- 自愈循环:重新启用凭据时同步清除 `disable_reason`
|
||||
- `mark_insufficient_balance()`: 清除 `auto_heal_reason` 防止被自愈循环错误恢复
|
||||
- 重命名内部字段以提高可读性
|
||||
- `DisabledReason` → `AutoHealReason`(自愈原因枚举)
|
||||
- `disabled_reason` → `auto_heal_reason`(自愈原因字段)
|
||||
- 日志中的 `user_id` 现在会进行掩码处理,保护用户隐私
|
||||
- 长度 > 25:保留前13后8字符(如 `user_f516339a***897ac7`)
|
||||
- 长度 13-25:保留前4后4字符
|
||||
- 长度 ≤ 12:完全掩码为 `***`
|
||||
|
||||
- 新增缓存余额查询 API(`GET /credentials/balances/cached`)
|
||||
- 后端:`CachedBalanceInfo` 结构体、`get_all_cached_balances()` 方法
|
||||
- 前端:凭据卡片直接显示缓存余额和更新时间
|
||||
- 30 秒自动轮询更新,缓存超过 1 分钟时点击强制刷新
|
||||
- 新增 Bonus 用量包支持(`src/kiro/model/usage_limits.rs`)
|
||||
- 新增 `Bonus` 结构体,支持 GIFT 类型的额外用量包
|
||||
- 新增 `Bonus::is_active()` 方法,按状态/过期时间判断是否激活
|
||||
- `usage_limit()` 和 `current_usage()` 现在会合并基础额度、免费试用额度和所有激活的 bonuses
|
||||
- 新增 Kiro Web Portal API 模块(`src/kiro/web_portal.rs`)
|
||||
- 支持 CBOR 协议与 app.kiro.dev 通信
|
||||
- 实现 `get_user_info()` 和 `get_user_usage_and_limits()` API
|
||||
- 新增 `aggregate_account_info()` 聚合账号信息(套餐/用量/邮箱等)
|
||||
- Admin UI 前端增强
|
||||
- 新增数字格式化工具(`admin-ui/src/lib/format.ts`):K/M/B 显示、Token 对格式化、过期时间格式化
|
||||
- 新增统计相关 API 和 Hooks:`getCredentialStats`, `resetCredentialStats`, `resetAllStats`
|
||||
- 新增账号信息 API:`getCredentialAccountInfo`, `useCredentialAccountInfo`
|
||||
- 扩展 `CredentialStatusItem` 添加统计字段(调用次数、Token 用量、最后调用时间等)
|
||||
- 新增完整的账号信息类型定义(`AccountAggregateInfo`, `CreditsUsageSummary` 等)
|
||||
- 新增 `serde_cbor` 依赖用于 CBOR 编解码
|
||||
|
||||
- 修复手动查询余额后列表页面不显示缓存余额的问题
|
||||
- `get_balance()` 成功后调用 `update_balance_cache()` 更新缓存
|
||||
- 现在点击"查看余额"后,列表页面会正确显示缓存的余额值
|
||||
- 修复关闭余额弹窗后卡片不更新缓存余额的问题
|
||||
- 弹窗关闭时调用 `queryClient.invalidateQueries({ queryKey: ['cached-balances'] })`
|
||||
- 确保卡片和弹窗使用的两个独立数据源保持同步
|
||||
- 增强 Token 刷新日志,添加凭证 ID 追踪
|
||||
- 新增 `refresh_token_with_id()` 函数支持传入凭证 ID
|
||||
- 日志现在包含 `credential_id` 字段,便于多凭据环境下的问题排查
|
||||
- 调整重试策略:单凭据最大重试次数 3→2,单请求最大重试次数 9→5
|
||||
- `MAX_RETRIES_PER_CREDENTIAL`: 3 → 2
|
||||
- `MAX_TOTAL_RETRIES`: 9 → 5
|
||||
- `MAX_FAILURES_PER_CREDENTIAL`: 3 → 2
|
||||
- 减少无效凭据的重试开销,加快故障转移速度
|
||||
|
||||
- 新增用户亲和性绑定功能:连续对话优先使用同一凭据(基于 `metadata.user_id`)
|
||||
- 新增 `src/kiro/affinity.rs` 模块,实现 `UserAffinityManager`
|
||||
- 新增 `acquire_context_for_user()` 方法支持亲和性查询
|
||||
- 亲和性绑定 TTL 为 30 分钟
|
||||
- 新增余额感知故障转移:凭据失效时自动切换到余额最高的可用凭据
|
||||
- 新增动态余额缓存 TTL 策略:
|
||||
- 高频渠道(10分钟内 ≥20 次调用):10 分钟刷新
|
||||
- 低频渠道:30 分钟刷新
|
||||
- 低余额渠道(余额 < 1.0):24 小时刷新
|
||||
- 新增 `record_usage()` 方法自动记录凭据使用频率
|
||||
- 新增负载均衡:无亲和性绑定时优先分配到使用频率最低的凭据
|
||||
- 新增 `DisableReason` 枚举,区分凭据禁用原因(失败次数、余额不足、模型不可用、手动禁用)
|
||||
- 成功请求时自动重置 `MODEL_TEMPORARILY_UNAVAILABLE` 计数器,避免跨时间累计触发
|
||||
- 新增 `MODEL_TEMPORARILY_UNAVAILABLE` 错误检测和全局禁用机制
|
||||
- 当该 500 错误发生 2 次时,自动禁用所有凭据
|
||||
- 5 分钟后自动恢复(余额不足的凭据除外)
|
||||
- `CredentialEntrySnapshot` 新增 `disable_reason` 字段,支持查询禁用原因
|
||||
- 新增自动余额刷新:成功请求后自动在后台刷新余额缓存(基于动态 TTL 策略)
|
||||
- 新增 `spawn_balance_refresh()` 方法,使用 `tokio::spawn` 异步刷新
|
||||
- 新增 `should_refresh_balance()` 方法,根据 TTL 判断是否需要刷新
|
||||
100
CLAUDE.md
Normal file
100
CLAUDE.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
kiro-rs 是一个用 Rust 编写的 Anthropic Claude API 兼容代理服务,将 Anthropic API 请求转换为 Kiro API 请求。支持多凭据管理、自动故障转移、流式响应和 Web 管理界面。
|
||||
|
||||
**技术栈**: Rust (Axum 0.8 + Tokio) + React 18 + TypeScript + Tailwind CSS
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 构建(必须先构建前端)
|
||||
cd admin-ui && pnpm install && pnpm build
|
||||
cargo build --release
|
||||
|
||||
# 开发运行
|
||||
cargo run -- -c config.json --credentials credentials.json
|
||||
|
||||
# 测试
|
||||
cargo test
|
||||
cargo test <test_name> # 运行单个测试
|
||||
|
||||
# 代码检查
|
||||
cargo fmt # 格式化
|
||||
cargo clippy # lint
|
||||
|
||||
# 启用敏感日志构建(排障用,输出 token 用量等诊断信息)
|
||||
cargo run --features sensitive-logs -- -c config.json --credentials credentials.json
|
||||
|
||||
# 前端开发
|
||||
cd admin-ui
|
||||
pnpm install
|
||||
pnpm dev # 开发服务器
|
||||
pnpm build # 生产构建
|
||||
```
|
||||
|
||||
## 请求处理流程
|
||||
|
||||
```
|
||||
POST /v1/messages (Anthropic 格式)
|
||||
→ auth_middleware: 验证 x-api-key / Bearer token(subtle 常量时间比较)
|
||||
→ post_messages handler:
|
||||
1. 判断 WebSearch 触发条件,决定本地处理或剔除后转发
|
||||
2. converter::convert_request() 转换为 Kiro 请求格式
|
||||
3. provider.call_api() 发送请求(含重试和故障转移)
|
||||
4. stream.rs 解析 AWS Event Stream → 转换为 Anthropic SSE 格式返回
|
||||
```
|
||||
|
||||
## 核心设计模式
|
||||
|
||||
1. **Provider Pattern** - `kiro/provider.rs`: 统一的 API 提供者接口,处理请求转发和重试。支持凭据级代理(每个凭据可配独立 HTTP/SOCKS5 代理,缓存对应 HTTP Client 避免重复创建)
|
||||
2. **Multi-Token Manager** - `kiro/token_manager.rs`: 多凭据管理,按优先级故障转移,后台异步刷新 Token(支持 Social 和 IdC 两种认证方式)。余额缓存动态 TTL:高频用户 10 分钟、低频用户 30 分钟、低余额用户 24 小时,过期时异步刷新不阻塞请求
|
||||
3. **Protocol Converter** - `anthropic/converter.rs`: Anthropic ↔ Kiro 双向协议转换,包括模型映射(sonnet/opus/haiku → Kiro 模型 ID)、JSON Schema 规范化(修复 MCP 工具的 `required: null` / `properties: null`)、工具占位符生成、图片格式转换
|
||||
4. **Event Stream Parser** - `kiro/parser/`: AWS Event Stream 二进制协议解析(header + payload + CRC32C 校验)
|
||||
5. **Buffered Stream** - `anthropic/stream.rs`: 两种流模式 — `StreamContext`(直接转发)和 `BufferedStreamContext`(缓冲所有事件,等 `contextUsageEvent` 到达后修正 input_tokens 再一次性发送)
|
||||
|
||||
## 共享状态
|
||||
|
||||
```rust
|
||||
AppState {
|
||||
api_key: String, // Anthropic API 认证密钥
|
||||
kiro_provider: Option<Arc<KiroProvider>>, // 核心 API 提供者(Arc 线程安全共享)
|
||||
profile_arn: Option<String>, // AWS Profile ARN
|
||||
compression_config: CompressionConfig, // 输入压缩配置
|
||||
}
|
||||
```
|
||||
|
||||
通过 Axum `State` extractor 注入到所有 handler 中。
|
||||
|
||||
## 凭据故障转移与冷却
|
||||
|
||||
- 凭据按 `priority` 字段排序,优先使用高优先级凭据
|
||||
- 请求失败时 `report_failure()` 触发故障转移到下一个可用凭据
|
||||
- 冷却分类管理:`FailureLimit` / `InsufficientBalance` / `ModelUnavailable` / `QuotaExceeded`
|
||||
- `MODEL_TEMPORARILY_UNAVAILABLE` 触发全局熔断,禁用所有凭据
|
||||
|
||||
## API 端点
|
||||
|
||||
**代理端点**:
|
||||
- `GET /v1/models` - 获取可用模型列表
|
||||
- `POST /v1/messages` - 创建消息(Anthropic 格式)
|
||||
- `POST /v1/messages/count_tokens` - Token 计数
|
||||
- `/cc/v1/*` - Claude Code 兼容端点(同上,路径别名)
|
||||
|
||||
**Admin API** (需配置 `adminApiKey`):
|
||||
- 凭据 CRUD、状态监控、余额查询
|
||||
|
||||
## 重要注意事项
|
||||
|
||||
1. **构建顺序**: 必须先构建前端 `admin-ui`,再编译 Rust 后端(静态文件通过 `rust-embed` 嵌入,derive 宏为 `#[derive(Embed)]`)
|
||||
2. **凭据格式**: 支持单凭据(向后兼容)和多凭据(数组格式,支持 priority 字段)
|
||||
3. **重试策略**: 单凭据最多重试 2 次,单请求最多重试 3 次
|
||||
4. **WebSearch 工具**: 仅当请求明确触发 WebSearch(`tool_choice` 强制 / 仅提供 `web_search` 单工具 / 消息前缀匹配)时走本地 WebSearch;否则从 `tools` 中剔除 `web_search` 后转发上游(避免误路由)
|
||||
5. **安全**: 使用 `subtle` 库进行常量时间比较防止时序攻击;Admin API Key 空字符串视为未配置
|
||||
6. **Prefill 处理**: Claude 4.x 已弃用 assistant prefill,末尾 assistant 消息被静默丢弃
|
||||
7. **sensitive-logs 特性**: 编译时 feature flag,启用后输出 token 用量诊断日志和请求体大小(默认关闭,仅用于排障)
|
||||
8. **网络错误分类**: 连接关闭/重置、发送失败等网络错误被归类为瞬态上游错误,返回 502(不记录请求体)
|
||||
9. **Rust edition**: 项目使用 Rust 2024 edition
|
||||
2587
Cargo.lock
generated
Normal file
2587
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
Cargo.toml
Normal file
42
Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "kiro-rs"
|
||||
version = "1.1.5"
|
||||
edition = "2024"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# 显式启用才允许打印潜在敏感信息(仅用于排障)
|
||||
sensitive-logs = []
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "socks", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_cbor = "0.11" # CBOR 编解码(Web Portal API)
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
anyhow = "1.0"
|
||||
http = "1.0"
|
||||
futures = "0.3"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.10", features = ["v1", "v4", "fast-rng"] }
|
||||
fastrand = "2"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
crc = "3" # CRC32C 计算
|
||||
bytes = "1" # 高效的字节缓冲区
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
urlencoding = "2"
|
||||
parking_lot = "0.12" # 高性能同步原语
|
||||
subtle = "2.6" # 常量时间比较(防止时序攻击)
|
||||
rust-embed = "8" # 嵌入静态文件
|
||||
mime_guess = "2" # MIME 类型推断
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } # 图片处理
|
||||
base64 = "0.22" # Base64 编解码
|
||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
FROM rust:1.93-alpine AS chef
|
||||
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
|
||||
RUN cargo install cargo-chef
|
||||
WORKDIR /app
|
||||
|
||||
FROM chef AS planner
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
COPY src ./src
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM node:24-alpine AS frontend-builder
|
||||
WORKDIR /app/admin-ui
|
||||
COPY admin-ui/package.json ./
|
||||
RUN npm install -g pnpm && pnpm install
|
||||
COPY admin-ui ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM chef AS builder
|
||||
|
||||
# 可选:启用敏感日志输出(仅用于排障)
|
||||
ARG ENABLE_SENSITIVE_LOGS=false
|
||||
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN if [ "$ENABLE_SENSITIVE_LOGS" = "true" ]; then \
|
||||
cargo chef cook --release --features sensitive-logs --recipe-path recipe.json; \
|
||||
else \
|
||||
cargo chef cook --release --recipe-path recipe.json; \
|
||||
fi
|
||||
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
COPY src ./src
|
||||
COPY --from=frontend-builder /app/admin-ui/dist /app/admin-ui/dist
|
||||
|
||||
RUN if [ "$ENABLE_SENSITIVE_LOGS" = "true" ]; then \
|
||||
cargo build --release --features sensitive-logs; \
|
||||
else \
|
||||
cargo build --release; \
|
||||
fi
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/kiro-rs /app/kiro-rs
|
||||
|
||||
VOLUME ["/app/config"]
|
||||
|
||||
EXPOSE 8990
|
||||
|
||||
CMD ["./kiro-rs", "-c", "/app/config/config.json", "--credentials", "/app/config/credentials.json"]
|
||||
68
Makefile
Normal file
68
Makefile
Normal file
@@ -0,0 +1,68 @@
|
||||
.PHONY: dev build release clean test lint fmt ui ui-dev docker help
|
||||
|
||||
# 默认目标
|
||||
help:
|
||||
@echo "Usage: make <target>"
|
||||
@echo ""
|
||||
@echo "开发:"
|
||||
@echo " dev cargo run(debug 模式,需先 make ui)"
|
||||
@echo " ui-dev 启动前端 dev server"
|
||||
@echo ""
|
||||
@echo "构建:"
|
||||
@echo " ui 构建前端"
|
||||
@echo " build 构建前端 + 后端(debug)"
|
||||
@echo " release 构建前端 + 后端(release)"
|
||||
@echo " docker 构建 Docker 镜像"
|
||||
@echo ""
|
||||
@echo "质量:"
|
||||
@echo " test 运行测试"
|
||||
@echo " lint cargo clippy"
|
||||
@echo " fmt cargo fmt"
|
||||
@echo " check fmt + clippy + test"
|
||||
@echo ""
|
||||
@echo "其他:"
|
||||
@echo " clean 清理构建产物"
|
||||
|
||||
# --- 前端 ---
|
||||
|
||||
ui:
|
||||
cd admin-ui && pnpm install && pnpm build
|
||||
|
||||
ui-dev:
|
||||
@echo "启动前端 dev server: http://localhost:5173"
|
||||
cd admin-ui && pnpm install && pnpm dev
|
||||
|
||||
# --- 后端 ---
|
||||
|
||||
dev: ui
|
||||
cargo run --features sensitive-logs -- -c config/config.json --credentials config/credentials.json
|
||||
|
||||
build: ui
|
||||
cargo build
|
||||
|
||||
release: ui
|
||||
cargo build --release
|
||||
|
||||
# --- 质量 ---
|
||||
|
||||
test:
|
||||
cargo test
|
||||
|
||||
lint:
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
fmt:
|
||||
cargo fmt
|
||||
|
||||
check: fmt lint test
|
||||
|
||||
# --- Docker ---
|
||||
|
||||
docker:
|
||||
docker build -t kiro-rs .
|
||||
|
||||
# --- 清理 ---
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf admin-ui/dist admin-ui/node_modules
|
||||
525
README.md
Normal file
525
README.md
Normal file
@@ -0,0 +1,525 @@
|
||||
# kiro-rs
|
||||
|
||||
一个用 Rust 编写的 Anthropic Claude API 兼容代理服务,将 Anthropic API 请求转换为 Kiro API 请求。
|
||||
|
||||
---
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅供研究使用, Use at your own risk, 使用本项目所导致的任何后果由使用人承担, 与本项目无关。
|
||||
本项目与 AWS/KIRO/Anthropic/Claude 等官方无关, 本项目不代表官方立场。
|
||||
|
||||
## 注意!
|
||||
|
||||
因 TLS 默认从 native-tls 切换至 rustls,你可能需要专门安装证书后才能配置 HTTP 代理。可通过 `config.json` 的 `tlsBackend` 切回 `native-tls`。
|
||||
如果遇到请求报错, 尤其是无法刷新 token, 或者是直接返回 error request, 请尝试切换 tls 后端为 `native-tls`, 一般即可解决。
|
||||
|
||||
**Write Failed/会话卡死**: 如果遇到持续的 Write File / Write Failed 并导致会话不可用,参考 Issue [#22](https://github.com/hank9999/kiro.rs/issues/22) 和 [#49](https://github.com/hank9999/kiro.rs/issues/49) 的说明与临时解决方案(通常与输出过长被截断有关,可尝试调低输出相关 token 上限)
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **Anthropic API 兼容**: 完整支持 Anthropic Claude API 格式
|
||||
- **流式响应**: 支持 SSE (Server-Sent Events) 流式输出
|
||||
- **Token 自动刷新**: 自动管理和刷新 OAuth Token
|
||||
- **多凭据支持**: 支持配置多个凭据,按优先级自动故障转移
|
||||
- **智能重试**: 单凭据最多重试 2 次,单请求最多重试 3 次
|
||||
- **凭据回写**: 多凭据格式下自动回写刷新后的 Token
|
||||
- **Thinking 模式**: 支持 Claude 的 extended thinking 功能
|
||||
- **工具调用**: 完整支持 function calling / tool use
|
||||
- **WebSearch**: 内置 WebSearch 工具转换逻辑
|
||||
- **多模型支持**: 支持 Sonnet、Opus、Haiku 系列模型
|
||||
- **Admin 管理**: 可选的 Web 管理界面和 API,支持凭据管理、余额查询等
|
||||
- **多级 Region 配置**: 支持全局和凭据级别的 Auth Region / API Region 配置
|
||||
- **凭据级代理**: 支持为每个凭据单独配置 HTTP/SOCKS5 代理,优先级:凭据代理 > 全局代理 > 无代理
|
||||
|
||||
---
|
||||
|
||||
- [开始](#开始)
|
||||
- [1. 编译](#1-编译)
|
||||
- [2. 最小配置](#2-最小配置)
|
||||
- [3. 启动](#3-启动)
|
||||
- [4. 验证](#4-验证)
|
||||
- [Docker](#docker)
|
||||
- [配置详解](#配置详解)
|
||||
- [config.json](#configjson)
|
||||
- [credentials.json](#credentialsjson)
|
||||
- [Region 配置](#region-配置)
|
||||
- [代理配置](#代理配置)
|
||||
- [认证方式](#认证方式)
|
||||
- [环境变量](#环境变量)
|
||||
- [API 端点](#api-端点)
|
||||
- [标准端点 (/v1)](#标准端点-v1)
|
||||
- [Claude Code 兼容端点 (/cc/v1)](#claude-code-兼容端点-ccv1)
|
||||
- [Thinking 模式](#thinking-模式)
|
||||
- [工具调用](#工具调用)
|
||||
- [模型映射](#模型映射)
|
||||
- [Admin(可选)](#admin可选)
|
||||
- [注意事项](#注意事项)
|
||||
- [项目结构](#项目结构)
|
||||
- [技术栈](#技术栈)
|
||||
- [License](#license)
|
||||
- [致谢](#致谢)
|
||||
|
||||
## 开始
|
||||
|
||||
### 1. 编译
|
||||
|
||||
> PS: 如果不想编辑可以直接前往 Release 下载二进制文件
|
||||
|
||||
> **前置步骤**:编译前需要先构建前端 Admin UI(用于嵌入到二进制中):
|
||||
> ```bash
|
||||
> cd admin-ui && pnpm install && pnpm build
|
||||
> ```
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### 2. 最小配置
|
||||
|
||||
创建 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 8990,
|
||||
"apiKey": "sk-kiro-rs-qazWSXedcRFV123456",
|
||||
"region": "us-east-1"
|
||||
}
|
||||
```
|
||||
> PS: 如果你需要 Web 管理面板, 请注意配置 `adminApiKey`
|
||||
|
||||
创建 `credentials.json`(从 Kiro IDE 等中获取凭证信息):
|
||||
> PS: 可以前往 Web 管理面板配置跳过本步骤
|
||||
> 如果你对凭据地域有疑惑, 请查看 [Region 配置](#region-配置)
|
||||
|
||||
Social 认证:
|
||||
```json
|
||||
{
|
||||
"refreshToken": "你的刷新token",
|
||||
"expiresAt": "2025-12-31T02:32:45.144Z",
|
||||
"authMethod": "social"
|
||||
}
|
||||
```
|
||||
|
||||
IdC 认证:
|
||||
```json
|
||||
{
|
||||
"refreshToken": "你的刷新token",
|
||||
"expiresAt": "2025-12-31T02:32:45.144Z",
|
||||
"authMethod": "idc",
|
||||
"clientId": "你的clientId",
|
||||
"clientSecret": "你的clientSecret"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 启动
|
||||
|
||||
```bash
|
||||
./target/release/kiro-rs
|
||||
```
|
||||
|
||||
或指定配置文件路径:
|
||||
|
||||
```bash
|
||||
./target/release/kiro-rs -c /path/to/config.json --credentials /path/to/credentials.json
|
||||
```
|
||||
|
||||
### 4. 验证
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8990/v1/messages \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: sk-kiro-rs-qazWSXedcRFV123456" \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 1024,
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, Claude!"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
也可以通过 Docker 启动:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
需要将 `config.json` 和 `credentials.json` 挂载到容器中,具体参见 `docker-compose.yml`。
|
||||
|
||||
## 配置详解
|
||||
|
||||
### config.json
|
||||
|
||||
| 字段 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `host` | string | `127.0.0.1` | 服务监听地址 |
|
||||
| `port` | number | `8080` | 服务监听端口 |
|
||||
| `apiKey` | string | - | 自定义 API Key(用于客户端认证,必配) |
|
||||
| `region` | string | `us-east-1` | AWS 区域 |
|
||||
| `authRegion` | string | - | Auth Region(用于 Token 刷新),未配置时回退到 region |
|
||||
| `apiRegion` | string | - | API Region(用于 API 请求),未配置时回退到 region |
|
||||
| `kiroVersion` | string | `0.10.0` | Kiro 版本号 |
|
||||
| `machineId` | string | - | 自定义机器码(64位十六进制),不定义则自动生成 |
|
||||
| `systemVersion` | string | 随机 | 系统版本标识 |
|
||||
| `nodeVersion` | string | `22.21.1` | Node.js 版本标识 |
|
||||
| `tlsBackend` | string | `rustls` | TLS 后端:`rustls` 或 `native-tls` |
|
||||
| `countTokensApiUrl` | string | - | 外部 count_tokens API 地址 |
|
||||
| `countTokensApiKey` | string | - | 外部 count_tokens API 密钥 |
|
||||
| `countTokensAuthType` | string | `x-api-key` | 外部 API 认证类型:`x-api-key` 或 `bearer` |
|
||||
| `proxyUrl` | string | - | HTTP/SOCKS5 代理地址 |
|
||||
| `proxyUsername` | string | - | 代理用户名 |
|
||||
| `proxyPassword` | string | - | 代理密码 |
|
||||
| `adminApiKey` | string | - | Admin API 密钥,配置后启用凭据管理 API 和 Web 管理界面 |
|
||||
|
||||
完整配置示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 8990,
|
||||
"apiKey": "sk-kiro-rs-qazWSXedcRFV123456",
|
||||
"region": "us-east-1",
|
||||
"tlsBackend": "rustls",
|
||||
"kiroVersion": "0.10.0",
|
||||
"machineId": "64位十六进制机器码",
|
||||
"systemVersion": "darwin#24.6.0",
|
||||
"nodeVersion": "22.21.1",
|
||||
"authRegion": "us-east-1",
|
||||
"apiRegion": "us-east-1",
|
||||
"countTokensApiUrl": "https://api.example.com/v1/messages/count_tokens",
|
||||
"countTokensApiKey": "sk-your-count-tokens-api-key",
|
||||
"countTokensAuthType": "x-api-key",
|
||||
"proxyUrl": "http://127.0.0.1:7890",
|
||||
"proxyUsername": "user",
|
||||
"proxyPassword": "pass",
|
||||
"adminApiKey": "sk-admin-your-secret-key"
|
||||
}
|
||||
```
|
||||
|
||||
### credentials.json
|
||||
|
||||
支持单对象格式(向后兼容)或数组格式(多凭据)。
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 描述 |
|
||||
|----------------|--------|---------------------------------------------|
|
||||
| `id` | number | 凭据唯一 ID(可选,仅用于 Admin API 管理;手写文件可不填) |
|
||||
| `accessToken` | string | OAuth 访问令牌(可选,可自动刷新) |
|
||||
| `refreshToken` | string | OAuth 刷新令牌 |
|
||||
| `profileArn` | string | AWS Profile ARN(可选,登录时返回) |
|
||||
| `expiresAt` | string | Token 过期时间 (RFC3339) |
|
||||
| `authMethod` | string | 认证方式:`social` 或 `idc` |
|
||||
| `clientId` | string | IdC 登录的客户端 ID(IdC 认证必填) |
|
||||
| `clientSecret` | string | IdC 登录的客户端密钥(IdC 认证必填) |
|
||||
| `priority` | number | 凭据优先级,数字越小越优先,默认为 0 |
|
||||
| `region` | string | 凭据级 Auth Region, 兼容字段 |
|
||||
| `authRegion` | string | 凭据级 Auth Region,用于 Token 刷新, 未配置时回退到 region |
|
||||
| `apiRegion` | string | 凭据级 API Region,用于 API 请求 |
|
||||
| `machineId` | string | 凭据级机器码(64位十六进制) |
|
||||
| `email` | string | 用户邮箱(可选,从 API 获取) |
|
||||
| `proxyUrl` | string | 凭据级代理 URL(可选,特殊值 `direct` 表示不使用代理) |
|
||||
| `proxyUsername`| string | 凭据级代理用户名(可选) |
|
||||
| `proxyPassword`| string | 凭据级代理密码(可选) |
|
||||
|
||||
说明:
|
||||
- IdC / Builder-ID / IAM 在本项目里属于同一种登录方式,配置时统一使用 `authMethod: "idc"`
|
||||
- 为兼容旧配置,`builder-id` / `iam` 仍可被识别,但会按 `idc` 处理
|
||||
|
||||
#### 单凭据格式(旧格式,向后兼容)
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "请求token,一般有效期一小时,可选",
|
||||
"refreshToken": "刷新token,一般有效期7-30天不等",
|
||||
"profileArn": "arn:aws:codewhisperer:us-east-1:111112222233:profile/QWER1QAZSDFGH",
|
||||
"expiresAt": "2025-12-31T02:32:45.144Z",
|
||||
"authMethod": "social",
|
||||
"clientId": "IdC 登录需要",
|
||||
"clientSecret": "IdC 登录需要"
|
||||
}
|
||||
```
|
||||
|
||||
#### 多凭据格式(支持故障转移和自动回写)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"refreshToken": "第一个凭据的刷新token",
|
||||
"expiresAt": "2025-12-31T02:32:45.144Z",
|
||||
"authMethod": "social",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"refreshToken": "第二个凭据的刷新token",
|
||||
"expiresAt": "2025-12-31T02:32:45.144Z",
|
||||
"authMethod": "idc",
|
||||
"clientId": "xxxxxxxxx",
|
||||
"clientSecret": "xxxxxxxxx",
|
||||
"region": "us-east-2",
|
||||
"priority": 1,
|
||||
"proxyUrl": "socks5://proxy.example.com:1080",
|
||||
"proxyUsername": "user",
|
||||
"proxyPassword": "pass"
|
||||
},
|
||||
{
|
||||
"refreshToken": "第三个凭据(显式不走代理)",
|
||||
"expiresAt": "2025-12-31T02:32:45.144Z",
|
||||
"authMethod": "social",
|
||||
"priority": 2,
|
||||
"proxyUrl": "direct"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
多凭据特性:
|
||||
- 按 `priority` 字段排序,数字越小优先级越高(默认为 0)
|
||||
- 单凭据最多重试 3 次,单请求最多重试 9 次
|
||||
- 自动故障转移到下一个可用凭据
|
||||
- 多凭据格式下 Token 刷新后自动回写到源文件
|
||||
|
||||
### Region 配置
|
||||
|
||||
支持多级 Region 配置,分别控制 Token 刷新和 API 请求使用的区域。
|
||||
|
||||
**Auth Region**(Token 刷新)优先级:
|
||||
`凭据.authRegion` > `凭据.region` > `config.authRegion` > `config.region`
|
||||
|
||||
**API Region**(API 请求)优先级:
|
||||
`凭据.apiRegion` > `config.apiRegion` > `config.region`
|
||||
|
||||
### 代理配置
|
||||
|
||||
支持全局代理和凭据级代理,凭据级代理会覆盖该凭据产生的所有出站连接(API 请求、Token 刷新、额度查询)。
|
||||
|
||||
**代理优先级**:`凭据.proxyUrl` > `config.proxyUrl` > 无代理
|
||||
|
||||
| 凭据 `proxyUrl` 值 | 行为 |
|
||||
|---|---|
|
||||
| 具体 URL(如 `http://proxy:8080`、`socks5://proxy:1080`) | 使用凭据指定的代理 |
|
||||
| `direct` | 显式不使用代理(即使全局配置了代理) |
|
||||
| 未配置(留空) | 回退到全局代理配置 |
|
||||
|
||||
凭据级代理示例:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"refreshToken": "凭据A:使用自己的代理",
|
||||
"authMethod": "social",
|
||||
"proxyUrl": "socks5://proxy-a.example.com:1080",
|
||||
"proxyUsername": "user_a",
|
||||
"proxyPassword": "pass_a"
|
||||
},
|
||||
{
|
||||
"refreshToken": "凭据B:显式不走代理(直连)",
|
||||
"authMethod": "social",
|
||||
"proxyUrl": "direct"
|
||||
},
|
||||
{
|
||||
"refreshToken": "凭据C:使用全局代理(或直连,取决于 config.json)",
|
||||
"authMethod": "social"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 认证方式
|
||||
|
||||
客户端请求本服务时,支持两种认证方式:
|
||||
|
||||
1. **x-api-key Header**
|
||||
```
|
||||
x-api-key: sk-your-api-key
|
||||
```
|
||||
|
||||
2. **Authorization Bearer**
|
||||
```
|
||||
Authorization: Bearer sk-your-api-key
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
可通过环境变量配置日志级别:
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug ./target/release/kiro-rs
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
### 标准端点 (/v1)
|
||||
|
||||
| 端点 | 方法 | 描述 |
|
||||
|------|------|------|
|
||||
| `/v1/models` | GET | 获取可用模型列表 |
|
||||
| `/v1/messages` | POST | 创建消息(对话) |
|
||||
| `/v1/messages/count_tokens` | POST | 估算 Token 数量 |
|
||||
|
||||
### Claude Code 兼容端点 (/cc/v1)
|
||||
|
||||
| 端点 | 方法 | 描述 |
|
||||
|------|------|------|
|
||||
| `/cc/v1/messages` | POST | 创建消息(缓冲模式,确保 `input_tokens` 准确) |
|
||||
| `/cc/v1/messages/count_tokens` | POST | 估算 Token 数量(与 `/v1` 相同) |
|
||||
|
||||
> **`/cc/v1/messages` 与 `/v1/messages` 的区别**:
|
||||
> - `/v1/messages`:实时流式返回,`message_start` 中的 `input_tokens` 是估算值
|
||||
> - `/cc/v1/messages`:缓冲模式,等待上游流完成后,用从 `contextUsageEvent` 计算的准确 `input_tokens` 更正 `message_start`,然后一次性返回所有事件
|
||||
> - 等待期间会每 25 秒发送 `ping` 事件保活
|
||||
|
||||
### Thinking 模式
|
||||
|
||||
支持 Claude 的 extended thinking 功能:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 16000,
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budget_tokens": 10000
|
||||
},
|
||||
"messages": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### 工具调用
|
||||
|
||||
完整支持 Anthropic 的 tool use 功能:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 1024,
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "获取指定城市的天气",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string"}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": [...]
|
||||
}
|
||||
```
|
||||
|
||||
## 模型映射
|
||||
|
||||
| Anthropic 模型 | Kiro 模型 |
|
||||
|----------------|-----------|
|
||||
| `*sonnet*`(含 4-6/4.6) | `claude-sonnet-4.6` |
|
||||
| `*sonnet*`(其他) | `claude-sonnet-4.5` |
|
||||
| `*opus*`(含 4-5/4.5) | `claude-opus-4.5` |
|
||||
| `*opus*`(其他) | `claude-opus-4.6` |
|
||||
| `*haiku*` | `claude-haiku-4.5` |
|
||||
|
||||
## Admin(可选)
|
||||
|
||||
当 `config.json` 配置了非空 `adminApiKey` 时,会启用:
|
||||
|
||||
- **Admin API(认证同 API Key)**
|
||||
- `GET /api/admin/credentials` - 获取所有凭据状态
|
||||
- `POST /api/admin/credentials` - 添加新凭据
|
||||
- `DELETE /api/admin/credentials/:id` - 删除凭据
|
||||
- `POST /api/admin/credentials/:id/disabled` - 设置凭据禁用状态
|
||||
- `POST /api/admin/credentials/:id/priority` - 设置凭据优先级
|
||||
- `POST /api/admin/credentials/:id/region` - 设置凭据 Region
|
||||
- `POST /api/admin/credentials/:id/reset` - 重置失败计数
|
||||
- `GET /api/admin/credentials/:id/balance` - 获取凭据余额
|
||||
|
||||
- **Admin UI**
|
||||
- `GET /admin` - 访问管理页面(需要在编译前构建 `admin-ui/dist`)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **凭证安全**: 请妥善保管 `credentials.json` 文件,不要提交到版本控制
|
||||
2. **Token 刷新**: 服务会自动刷新过期的 Token,无需手动干预
|
||||
3. **WebSearch 工具**: 只要 `tools` 中包含 `web_search`(按 name 或 type 判断),就走内置 WebSearch 处理逻辑
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
kiro-rs/
|
||||
├── src/
|
||||
│ ├── main.rs # 程序入口
|
||||
│ ├── http_client.rs # HTTP 客户端构建
|
||||
│ ├── token.rs # Token 计算模块
|
||||
│ ├── debug.rs # 调试工具
|
||||
│ ├── test.rs # 测试
|
||||
│ ├── model/ # 配置和参数模型
|
||||
│ │ ├── config.rs # 应用配置
|
||||
│ │ └── arg.rs # 命令行参数
|
||||
│ ├── anthropic/ # Anthropic API 兼容层
|
||||
│ │ ├── router.rs # 路由配置
|
||||
│ │ ├── handlers.rs # 请求处理器
|
||||
│ │ ├── middleware.rs # 认证中间件
|
||||
│ │ ├── types.rs # 类型定义
|
||||
│ │ ├── converter.rs # 协议转换器
|
||||
│ │ ├── stream.rs # 流式响应处理
|
||||
│ │ └── websearch.rs # WebSearch 工具处理
|
||||
│ ├── kiro/ # Kiro API 客户端
|
||||
│ │ ├── provider.rs # API 提供者
|
||||
│ │ ├── token_manager.rs # Token 管理
|
||||
│ │ ├── machine_id.rs # 设备指纹生成
|
||||
│ │ ├── model/ # 数据模型
|
||||
│ │ │ ├── credentials.rs # OAuth 凭证
|
||||
│ │ │ ├── events/ # 响应事件类型
|
||||
│ │ │ ├── requests/ # 请求类型
|
||||
│ │ │ ├── common/ # 共享类型
|
||||
│ │ │ ├── token_refresh.rs # Token 刷新模型
|
||||
│ │ │ └── usage_limits.rs # 使用额度模型
|
||||
│ │ └── parser/ # AWS Event Stream 解析器
|
||||
│ │ ├── decoder.rs # 流式解码器
|
||||
│ │ ├── frame.rs # 帧解析
|
||||
│ │ ├── header.rs # 头部解析
|
||||
│ │ ├── error.rs # 错误类型
|
||||
│ │ └── crc.rs # CRC 校验
|
||||
│ ├── admin/ # Admin API 模块
|
||||
│ │ ├── router.rs # 路由配置
|
||||
│ │ ├── handlers.rs # 请求处理器
|
||||
│ │ ├── service.rs # 业务逻辑服务
|
||||
│ │ ├── types.rs # 类型定义
|
||||
│ │ ├── middleware.rs # 认证中间件
|
||||
│ │ └── error.rs # 错误处理
|
||||
│ ├── admin_ui/ # Admin UI 静态文件嵌入
|
||||
│ │ └── router.rs # 静态文件路由
|
||||
│ └── common/ # 公共模块
|
||||
│ └── auth.rs # 认证工具函数
|
||||
├── admin-ui/ # Admin UI 前端工程(构建产物会嵌入二进制)
|
||||
├── tools/ # 辅助工具
|
||||
├── Cargo.toml # 项目配置
|
||||
├── config.example.json # 配置示例
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
└── Dockerfile # Docker 构建文件
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Web 框架**: [Axum](https://github.com/tokio-rs/axum) 0.8
|
||||
- **异步运行时**: [Tokio](https://tokio.rs/)
|
||||
- **HTTP 客户端**: [Reqwest](https://github.com/seanmonstar/reqwest)
|
||||
- **序列化**: [Serde](https://serde.rs/)
|
||||
- **日志**: [tracing](https://github.com/tokio-rs/tracing)
|
||||
- **命令行**: [Clap](https://github.com/clap-rs/clap)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目的实现离不开前辈的努力:
|
||||
- [kiro2api](https://github.com/caidaoli/kiro2api)
|
||||
- [proxycast](https://github.com/aiclientproxy/proxycast)
|
||||
|
||||
本项目部分逻辑参考了以上的项目, 再次由衷的感谢!
|
||||
480
admin-ui/bun.lock
Normal file
480
admin-ui/bun.lock
Normal file
@@ -0,0 +1,480 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "kiro-admin-ui",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"axios": "^1.13.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.4", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.4.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.5.tgz", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||
|
||||
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||
|
||||
"@swc/core": ["@swc/core@1.15.11", "https://registry.npmmirror.com/@swc/core/-/core-1.15.11.tgz", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.11", "@swc/core-darwin-x64": "1.15.11", "@swc/core-linux-arm-gnueabihf": "1.15.11", "@swc/core-linux-arm64-gnu": "1.15.11", "@swc/core-linux-arm64-musl": "1.15.11", "@swc/core-linux-x64-gnu": "1.15.11", "@swc/core-linux-x64-musl": "1.15.11", "@swc/core-win32-arm64-msvc": "1.15.11", "@swc/core-win32-ia32-msvc": "1.15.11", "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w=="],
|
||||
|
||||
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.11", "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg=="],
|
||||
|
||||
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.11", "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA=="],
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", { "os": "linux", "cpu": "arm" }, "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg=="],
|
||||
|
||||
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA=="],
|
||||
|
||||
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w=="],
|
||||
|
||||
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", { "os": "linux", "cpu": "x64" }, "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ=="],
|
||||
|
||||
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", { "os": "linux", "cpu": "x64" }, "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw=="],
|
||||
|
||||
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA=="],
|
||||
|
||||
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw=="],
|
||||
|
||||
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", { "os": "win32", "cpu": "x64" }, "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw=="],
|
||||
|
||||
"@swc/counter": ["@swc/counter@0.1.3", "https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/types": ["@swc/types@0.1.25", "https://registry.npmmirror.com/@swc/types/-/types-0.1.25.tgz", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.18.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.18.tgz", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.20.tgz", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.20.tgz", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.10", "https://registry.npmmirror.com/@types/react/-/react-19.2.10.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.2.2", "https://registry.npmmirror.com/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.47", "@swc/core": "^1.13.5" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.24", "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.24.tgz", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
|
||||
|
||||
"axios": ["axios@1.13.4", "https://registry.npmmirror.com/axios/-/axios-1.13.4.tgz", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001766", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.283", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", {}, "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.2", "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.563.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.563.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"react": ["react@19.2.4", "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"rollup": ["rollup@4.57.1", "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
}
|
||||
}
|
||||
13
admin-ui/index.html
Normal file
13
admin-ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kiro Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
admin-ui/postcss.config.js
Normal file
6
admin-ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
admin-ui/public/vite.svg
Normal file
1
admin-ui/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
37
admin-ui/src/App.tsx
Normal file
37
admin-ui/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { LoginPage } from '@/components/login-page'
|
||||
import { Dashboard } from '@/components/dashboard'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
function App() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 检查是否已经有保存的 API Key
|
||||
if (storage.getApiKey()) {
|
||||
setIsLoggedIn(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsLoggedIn(true)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
setIsLoggedIn(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoggedIn ? (
|
||||
<Dashboard onLogout={handleLogout} />
|
||||
) : (
|
||||
<LoginPage onLogin={handleLogin} />
|
||||
)}
|
||||
<Toaster position="top-right" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
147
admin-ui/src/api/credentials.ts
Normal file
147
admin-ui/src/api/credentials.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import axios from 'axios'
|
||||
import { storage } from '@/lib/storage'
|
||||
import type {
|
||||
CredentialsStatusResponse,
|
||||
BalanceResponse,
|
||||
CachedBalancesResponse,
|
||||
SuccessResponse,
|
||||
SetDisabledRequest,
|
||||
SetPriorityRequest,
|
||||
AddCredentialRequest,
|
||||
AddCredentialResponse,
|
||||
CredentialStatsResponse,
|
||||
CredentialAccountInfoResponse,
|
||||
ImportTokenJsonRequest,
|
||||
ImportTokenJsonResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
// 创建 axios 实例
|
||||
const api = axios.create({
|
||||
baseURL: '/api/admin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器添加 API Key
|
||||
api.interceptors.request.use((config) => {
|
||||
const apiKey = storage.getApiKey()
|
||||
if (apiKey) {
|
||||
config.headers['x-api-key'] = apiKey
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 获取所有凭据状态
|
||||
export async function getCredentials(): Promise<CredentialsStatusResponse> {
|
||||
const { data } = await api.get<CredentialsStatusResponse>('/credentials')
|
||||
return data
|
||||
}
|
||||
|
||||
// 设置凭据禁用状态
|
||||
export async function setCredentialDisabled(
|
||||
id: number,
|
||||
disabled: boolean
|
||||
): Promise<SuccessResponse> {
|
||||
const { data } = await api.post<SuccessResponse>(
|
||||
`/credentials/${id}/disabled`,
|
||||
{ disabled } as SetDisabledRequest
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
// 设置凭据优先级
|
||||
export async function setCredentialPriority(
|
||||
id: number,
|
||||
priority: number
|
||||
): Promise<SuccessResponse> {
|
||||
const { data } = await api.post<SuccessResponse>(
|
||||
`/credentials/${id}/priority`,
|
||||
{ priority } as SetPriorityRequest
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
// 重置失败计数
|
||||
export async function resetCredentialFailure(
|
||||
id: number
|
||||
): Promise<SuccessResponse> {
|
||||
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/reset`)
|
||||
return data
|
||||
}
|
||||
|
||||
// 设置凭据 Region
|
||||
export async function setCredentialRegion(
|
||||
id: number,
|
||||
region: string | null,
|
||||
apiRegion: string | null
|
||||
): Promise<SuccessResponse> {
|
||||
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/region`, {
|
||||
region: region || null,
|
||||
apiRegion: apiRegion || null,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
// 获取凭据余额
|
||||
export async function getCredentialBalance(id: number): Promise<BalanceResponse> {
|
||||
const { data } = await api.get<BalanceResponse>(`/credentials/${id}/balance`)
|
||||
return data
|
||||
}
|
||||
|
||||
// 获取所有凭据的缓存余额
|
||||
export async function getCachedBalances(): Promise<CachedBalancesResponse> {
|
||||
const { data } = await api.get<CachedBalancesResponse>('/credentials/balances/cached')
|
||||
return data
|
||||
}
|
||||
|
||||
// 获取凭据账号信息(套餐/用量/邮箱等)
|
||||
export async function getCredentialAccountInfo(
|
||||
id: number
|
||||
): Promise<CredentialAccountInfoResponse> {
|
||||
const { data } = await api.get<CredentialAccountInfoResponse>(`/credentials/${id}/account`)
|
||||
return data
|
||||
}
|
||||
|
||||
// 添加新凭据
|
||||
export async function addCredential(
|
||||
req: AddCredentialRequest
|
||||
): Promise<AddCredentialResponse> {
|
||||
const { data } = await api.post<AddCredentialResponse>('/credentials', req)
|
||||
return data
|
||||
}
|
||||
|
||||
// 删除凭据
|
||||
export async function deleteCredential(id: number): Promise<SuccessResponse> {
|
||||
const { data } = await api.delete<SuccessResponse>(`/credentials/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
// 获取指定凭据统计
|
||||
export async function getCredentialStats(id: number): Promise<CredentialStatsResponse> {
|
||||
const { data } = await api.get<CredentialStatsResponse>(`/credentials/${id}/stats`)
|
||||
return data
|
||||
}
|
||||
|
||||
// 清空指定凭据统计
|
||||
export async function resetCredentialStats(id: number): Promise<SuccessResponse> {
|
||||
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/stats/reset`)
|
||||
return data
|
||||
}
|
||||
|
||||
// 清空全部统计
|
||||
export async function resetAllStats(): Promise<SuccessResponse> {
|
||||
const { data } = await api.post<SuccessResponse>('/stats/reset')
|
||||
return data
|
||||
}
|
||||
|
||||
// 批量导入 token.json
|
||||
export async function importTokenJson(
|
||||
req: ImportTokenJsonRequest
|
||||
): Promise<ImportTokenJsonResponse> {
|
||||
const { data } = await api.post<ImportTokenJsonResponse>(
|
||||
'/credentials/import-token-json',
|
||||
req
|
||||
)
|
||||
return data
|
||||
}
|
||||
279
admin-ui/src/components/add-credential-dialog.tsx
Normal file
279
admin-ui/src/components/add-credential-dialog.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useAddCredential } from '@/hooks/use-credentials'
|
||||
import { extractErrorMessage } from '@/lib/utils'
|
||||
|
||||
interface AddCredentialDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type AuthMethod = 'social' | 'idc'
|
||||
|
||||
export function AddCredentialDialog({ open, onOpenChange }: AddCredentialDialogProps) {
|
||||
const [refreshToken, setRefreshToken] = useState('')
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>('social')
|
||||
const [region, setRegion] = useState('')
|
||||
const [apiRegion, setApiRegion] = useState('')
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [priority, setPriority] = useState('0')
|
||||
const [machineId, setMachineId] = useState('')
|
||||
const [proxyUrl, setProxyUrl] = useState('')
|
||||
const [proxyUsername, setProxyUsername] = useState('')
|
||||
const [proxyPassword, setProxyPassword] = useState('')
|
||||
|
||||
const { mutate, isPending } = useAddCredential()
|
||||
|
||||
const resetForm = () => {
|
||||
setRefreshToken('')
|
||||
setAuthMethod('social')
|
||||
setRegion('')
|
||||
setApiRegion('')
|
||||
setClientId('')
|
||||
setClientSecret('')
|
||||
setPriority('0')
|
||||
setMachineId('')
|
||||
setProxyUrl('')
|
||||
setProxyUsername('')
|
||||
setProxyPassword('')
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// 验证必填字段
|
||||
if (!refreshToken.trim()) {
|
||||
toast.error('请输入 Refresh Token')
|
||||
return
|
||||
}
|
||||
|
||||
// IdC/Builder-ID/IAM 需要额外字段
|
||||
if (authMethod === 'idc' && (!clientId.trim() || !clientSecret.trim())) {
|
||||
toast.error('IdC/Builder-ID/IAM 认证需要填写 Client ID 和 Client Secret')
|
||||
return
|
||||
}
|
||||
|
||||
mutate(
|
||||
{
|
||||
refreshToken: refreshToken.trim(),
|
||||
authMethod,
|
||||
region: region.trim() || undefined,
|
||||
apiRegion: apiRegion.trim() || undefined,
|
||||
clientId: clientId.trim() || undefined,
|
||||
clientSecret: clientSecret.trim() || undefined,
|
||||
priority: parseInt(priority) || 0,
|
||||
machineId: machineId.trim() || undefined,
|
||||
proxyUrl: proxyUrl.trim() || undefined,
|
||||
proxyUsername: proxyUsername.trim() || undefined,
|
||||
proxyPassword: proxyPassword.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(`添加失败: ${extractErrorMessage(error)}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加凭据</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
|
||||
<div className="space-y-4 py-4 overflow-y-auto flex-1 pr-1">
|
||||
{/* Refresh Token */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="refreshToken" className="text-sm font-medium">
|
||||
Refresh Token <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="refreshToken"
|
||||
type="password"
|
||||
placeholder="请输入 Refresh Token"
|
||||
value={refreshToken}
|
||||
onChange={(e) => setRefreshToken(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 认证方式 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="authMethod" className="text-sm font-medium">
|
||||
认证方式
|
||||
</label>
|
||||
<select
|
||||
id="authMethod"
|
||||
value={authMethod}
|
||||
onChange={(e) => setAuthMethod(e.target.value as AuthMethod)}
|
||||
disabled={isPending}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="social">Social</option>
|
||||
<option value="idc">IdC/Builder-ID/IAM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Region 配置 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Region 配置</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Input
|
||||
id="region"
|
||||
placeholder="Region"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="apiRegion"
|
||||
placeholder="API Region(可选覆盖)"
|
||||
value={apiRegion}
|
||||
onChange={(e) => setApiRegion(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Region 用于 Token 刷新,留空使用全局配置。API Region 可单独覆盖 API 请求所用的 Region
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* IdC/Builder-ID/IAM 额外字段 */}
|
||||
{authMethod === 'idc' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="clientId" className="text-sm font-medium">
|
||||
Client ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="clientId"
|
||||
placeholder="请输入 Client ID"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="clientSecret" className="text-sm font-medium">
|
||||
Client Secret <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
placeholder="请输入 Client Secret"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 优先级 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="priority" className="text-sm font-medium">
|
||||
优先级
|
||||
</label>
|
||||
<Input
|
||||
id="priority"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="数字越小优先级越高"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
数字越小优先级越高,默认为 0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Machine ID */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="machineId" className="text-sm font-medium">
|
||||
Machine ID
|
||||
</label>
|
||||
<Input
|
||||
id="machineId"
|
||||
placeholder="留空使用配置中字段, 否则由刷新Token自动派生"
|
||||
value={machineId}
|
||||
onChange={(e) => setMachineId(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
可选,64 位十六进制字符串,留空使用配置中字段, 否则由刷新Token自动派生
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 代理配置 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">代理配置</label>
|
||||
<Input
|
||||
id="proxyUrl"
|
||||
placeholder='代理 URL(留空使用全局配置,"direct" 不使用代理)'
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
id="proxyUsername"
|
||||
placeholder="代理用户名"
|
||||
value={proxyUsername}
|
||||
onChange={(e) => setProxyUsername(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Input
|
||||
id="proxyPassword"
|
||||
type="password"
|
||||
placeholder="代理密码"
|
||||
value={proxyPassword}
|
||||
onChange={(e) => setProxyPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
留空使用全局代理。输入 "direct" 可显式不使用代理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? '添加中...' : '添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
106
admin-ui/src/components/balance-dialog.tsx
Normal file
106
admin-ui/src/components/balance-dialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useCredentialBalance } from '@/hooks/use-credentials'
|
||||
import { parseError } from '@/lib/utils'
|
||||
|
||||
interface BalanceDialogProps {
|
||||
credentialId: number | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
forceRefresh?: boolean
|
||||
}
|
||||
|
||||
export function BalanceDialog({ credentialId, open, onOpenChange, forceRefresh }: BalanceDialogProps) {
|
||||
const { data: balance, isLoading, isFetching, error } = useCredentialBalance(credentialId)
|
||||
const showLoading = isLoading || (forceRefresh && isFetching)
|
||||
|
||||
const formatDate = (timestamp: number | null) => {
|
||||
if (!timestamp) return '未知'
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
凭据 #{credentialId} 余额信息
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{showLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (() => {
|
||||
const parsed = parseError(error)
|
||||
return (
|
||||
<div className="py-6 space-y-3">
|
||||
<div className="flex items-center justify-center gap-2 text-red-500">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="font-medium">{parsed.title}</span>
|
||||
</div>
|
||||
{parsed.detail && (
|
||||
<div className="text-sm text-muted-foreground text-center px-4">
|
||||
{parsed.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{balance && (
|
||||
<div className="space-y-4">
|
||||
{/* 订阅类型 */}
|
||||
<div className="text-center">
|
||||
<span className="text-lg font-semibold">
|
||||
{balance.subscriptionTitle || '未知订阅类型'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 使用进度 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>已使用: ${formatNumber(balance.currentUsage)}</span>
|
||||
<span>限额: ${formatNumber(balance.usageLimit)}</span>
|
||||
</div>
|
||||
<Progress value={balance.usagePercentage} />
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{balance.usagePercentage.toFixed(1)}% 已使用
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">剩余额度:</span>
|
||||
<span className="font-medium text-green-600">
|
||||
${formatNumber(balance.remaining)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">下次重置:</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(balance.nextResetAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
434
admin-ui/src/components/batch-import-dialog.tsx
Normal file
434
admin-ui/src/components/batch-import-dialog.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useCredentials, useAddCredential, useDeleteCredential } from '@/hooks/use-credentials'
|
||||
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
|
||||
import { extractErrorMessage, sha256Hex } from '@/lib/utils'
|
||||
|
||||
interface BatchImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface CredentialInput {
|
||||
refreshToken: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
region?: string
|
||||
authRegion?: string
|
||||
apiRegion?: string
|
||||
priority?: number
|
||||
machineId?: string
|
||||
}
|
||||
|
||||
interface VerificationResult {
|
||||
index: number
|
||||
status: 'pending' | 'checking' | 'verifying' | 'verified' | 'duplicate' | 'failed'
|
||||
error?: string
|
||||
usage?: string
|
||||
email?: string
|
||||
credentialId?: number
|
||||
rollbackStatus?: 'success' | 'failed' | 'skipped'
|
||||
rollbackError?: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function BatchImportDialog({ open, onOpenChange }: BatchImportDialogProps) {
|
||||
const [jsonInput, setJsonInput] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
const [currentProcessing, setCurrentProcessing] = useState<string>('')
|
||||
const [results, setResults] = useState<VerificationResult[]>([])
|
||||
|
||||
const { data: existingCredentials } = useCredentials()
|
||||
const { mutateAsync: addCredential } = useAddCredential()
|
||||
const { mutateAsync: deleteCredential } = useDeleteCredential()
|
||||
|
||||
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await setCredentialDisabled(id, true)
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `禁用失败: ${extractErrorMessage(error)}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCredential(id)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `删除失败: ${extractErrorMessage(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setJsonInput('')
|
||||
setProgress({ current: 0, total: 0 })
|
||||
setCurrentProcessing('')
|
||||
setResults([])
|
||||
}
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
// 先单独解析 JSON,给出精准的错误提示
|
||||
let credentials: CredentialInput[]
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput)
|
||||
credentials = Array.isArray(parsed) ? parsed : [parsed]
|
||||
} catch (error) {
|
||||
toast.error('JSON 格式错误: ' + extractErrorMessage(error))
|
||||
return
|
||||
}
|
||||
|
||||
if (credentials.length === 0) {
|
||||
toast.error('没有可导入的凭据')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setImporting(true)
|
||||
setProgress({ current: 0, total: credentials.length })
|
||||
|
||||
// 2. 初始化结果
|
||||
const initialResults: VerificationResult[] = credentials.map((_, i) => ({
|
||||
index: i + 1,
|
||||
status: 'pending'
|
||||
}))
|
||||
setResults(initialResults)
|
||||
|
||||
// 3. 检测重复
|
||||
const existingTokenHashes = new Set(
|
||||
existingCredentials?.credentials
|
||||
.map(c => c.refreshTokenHash)
|
||||
.filter((hash): hash is string => Boolean(hash)) || []
|
||||
)
|
||||
|
||||
let successCount = 0
|
||||
let duplicateCount = 0
|
||||
let failCount = 0
|
||||
let rollbackSuccessCount = 0
|
||||
let rollbackFailedCount = 0
|
||||
let rollbackSkippedCount = 0
|
||||
|
||||
// 4. 导入并验活
|
||||
for (let i = 0; i < credentials.length; i++) {
|
||||
const cred = credentials[i]
|
||||
const token = cred.refreshToken.trim()
|
||||
const tokenHash = await sha256Hex(token)
|
||||
|
||||
// 更新状态为检查中
|
||||
setCurrentProcessing(`正在处理凭据 ${i + 1}/${credentials.length}`)
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = { ...newResults[i], status: 'checking' }
|
||||
return newResults
|
||||
})
|
||||
|
||||
// 检查重复
|
||||
if (existingTokenHashes.has(tokenHash)) {
|
||||
duplicateCount++
|
||||
const existingCred = existingCredentials?.credentials.find(c => c.refreshTokenHash === tokenHash)
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = {
|
||||
...newResults[i],
|
||||
status: 'duplicate',
|
||||
error: '该凭据已存在',
|
||||
email: existingCred?.email || undefined
|
||||
}
|
||||
return newResults
|
||||
})
|
||||
setProgress({ current: i + 1, total: credentials.length })
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新状态为验活中
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = { ...newResults[i], status: 'verifying' }
|
||||
return newResults
|
||||
})
|
||||
|
||||
let addedCredId: number | null = null
|
||||
|
||||
try {
|
||||
// 添加凭据
|
||||
const clientId = cred.clientId?.trim() || undefined
|
||||
const clientSecret = cred.clientSecret?.trim() || undefined
|
||||
const authMethod = clientId && clientSecret ? 'idc' : 'social'
|
||||
|
||||
// idc 模式下必须同时提供 clientId 和 clientSecret
|
||||
if (authMethod === 'social' && (clientId || clientSecret)) {
|
||||
throw new Error('idc 模式需要同时提供 clientId 和 clientSecret')
|
||||
}
|
||||
|
||||
const addedCred = await addCredential({
|
||||
refreshToken: token,
|
||||
authMethod,
|
||||
authRegion: cred.authRegion?.trim() || cred.region?.trim() || undefined,
|
||||
apiRegion: cred.apiRegion?.trim() || undefined,
|
||||
clientId,
|
||||
clientSecret,
|
||||
priority: cred.priority || 0,
|
||||
machineId: cred.machineId?.trim() || undefined,
|
||||
})
|
||||
|
||||
addedCredId = addedCred.credentialId
|
||||
|
||||
// 延迟 1 秒
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 验活
|
||||
const balance = await getCredentialBalance(addedCred.credentialId)
|
||||
|
||||
// 验活成功
|
||||
successCount++
|
||||
existingTokenHashes.add(tokenHash)
|
||||
setCurrentProcessing(addedCred.email ? `验活成功: ${addedCred.email}` : `验活成功: 凭据 ${i + 1}`)
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = {
|
||||
...newResults[i],
|
||||
status: 'verified',
|
||||
usage: `${balance.currentUsage}/${balance.usageLimit}`,
|
||||
email: addedCred.email || undefined,
|
||||
credentialId: addedCred.credentialId
|
||||
}
|
||||
return newResults
|
||||
})
|
||||
} catch (error) {
|
||||
// 验活失败,尝试回滚(先禁用再删除)
|
||||
let rollbackStatus: VerificationResult['rollbackStatus'] = 'skipped'
|
||||
let rollbackError: string | undefined
|
||||
|
||||
if (addedCredId) {
|
||||
const rollbackResult = await rollbackCredential(addedCredId)
|
||||
if (rollbackResult.success) {
|
||||
rollbackStatus = 'success'
|
||||
rollbackSuccessCount++
|
||||
} else {
|
||||
rollbackStatus = 'failed'
|
||||
rollbackFailedCount++
|
||||
rollbackError = rollbackResult.error
|
||||
}
|
||||
} else {
|
||||
rollbackSkippedCount++
|
||||
}
|
||||
|
||||
failCount++
|
||||
setResults(prev => {
|
||||
const newResults = [...prev]
|
||||
newResults[i] = {
|
||||
...newResults[i],
|
||||
status: 'failed',
|
||||
error: extractErrorMessage(error),
|
||||
email: undefined,
|
||||
rollbackStatus,
|
||||
rollbackError,
|
||||
}
|
||||
return newResults
|
||||
})
|
||||
}
|
||||
|
||||
setProgress({ current: i + 1, total: credentials.length })
|
||||
}
|
||||
|
||||
// 显示结果
|
||||
if (failCount === 0 && duplicateCount === 0) {
|
||||
toast.success(`成功导入并验活 ${successCount} 个凭据`)
|
||||
} else {
|
||||
const failureSummary = failCount > 0
|
||||
? `,失败 ${failCount} 个(已排除 ${rollbackSuccessCount},未排除 ${rollbackFailedCount},无需排除 ${rollbackSkippedCount})`
|
||||
: ''
|
||||
toast.info(`验活完成:成功 ${successCount} 个,重复 ${duplicateCount} 个${failureSummary}`)
|
||||
|
||||
if (rollbackFailedCount > 0) {
|
||||
toast.warning(`有 ${rollbackFailedCount} 个失败凭据回滚未完成,请手动禁用并删除`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('导入失败: ' + extractErrorMessage(error))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: VerificationResult['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
|
||||
case 'checking':
|
||||
case 'verifying':
|
||||
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
||||
case 'verified':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
case 'duplicate':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (result: VerificationResult) => {
|
||||
switch (result.status) {
|
||||
case 'pending':
|
||||
return '等待中'
|
||||
case 'checking':
|
||||
return '检查重复...'
|
||||
case 'verifying':
|
||||
return '验活中...'
|
||||
case 'verified':
|
||||
return '验活成功'
|
||||
case 'duplicate':
|
||||
return '重复凭据'
|
||||
case 'failed':
|
||||
if (result.rollbackStatus === 'success') return '验活失败(已排除)'
|
||||
if (result.rollbackStatus === 'failed') return '验活失败(未排除)'
|
||||
return '验活失败(未创建)'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
// 关闭时清空表单(但不在导入过程中清空)
|
||||
if (!newOpen && !importing) {
|
||||
resetForm()
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入凭据(自动验活)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
JSON 格式凭据
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={'粘贴 JSON 格式的凭据(支持单个对象或数组)\n例如: [{"refreshToken":"...","clientId":"...","clientSecret":"...","authRegion":"us-east-1","apiRegion":"us-west-2"}]\n支持 region 字段自动映射为 authRegion'}
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
disabled={importing}
|
||||
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 导入时自动验活,失败的凭据会被排除
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(importing || results.length > 0) && (
|
||||
<>
|
||||
{/* 进度条 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{importing ? '验活进度' : '验活完成'}</span>
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{importing && currentProcessing && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{currentProcessing}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计 */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
✓ 成功: {results.filter(r => r.status === 'verified').length}
|
||||
</span>
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
⚠ 重复: {results.filter(r => r.status === 'duplicate').length}
|
||||
</span>
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
✗ 失败: {results.filter(r => r.status === 'failed').length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
|
||||
{results.map((result) => (
|
||||
<div key={result.index} className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{getStatusIcon(result.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{result.email || `凭据 #${result.index}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getStatusText(result)}
|
||||
</span>
|
||||
</div>
|
||||
{result.usage && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
用量: {result.usage}
|
||||
</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
{result.rollbackError && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
回滚失败: {result.rollbackError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? '验活中...' : results.length > 0 ? '关闭' : '取消'}
|
||||
</Button>
|
||||
{results.length === 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleBatchImport}
|
||||
disabled={importing || !jsonInput.trim()}
|
||||
>
|
||||
开始导入并验活
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
152
admin-ui/src/components/batch-verify-dialog.tsx
Normal file
152
admin-ui/src/components/batch-verify-dialog.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export interface VerifyResult {
|
||||
id: number
|
||||
status: 'pending' | 'verifying' | 'success' | 'failed'
|
||||
usage?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BatchVerifyDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
verifying: boolean
|
||||
progress: { current: number; total: number }
|
||||
results: Map<number, VerifyResult>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function BatchVerifyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
verifying,
|
||||
progress,
|
||||
results,
|
||||
onCancel,
|
||||
}: BatchVerifyDialogProps) {
|
||||
const resultsArray = Array.from(results.values())
|
||||
const successCount = resultsArray.filter(r => r.status === 'success').length
|
||||
const failedCount = resultsArray.filter(r => r.status === 'failed').length
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量验活</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 进度显示 */}
|
||||
{verifying && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>验活进度</span>
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计信息 */}
|
||||
{results.size > 0 && (
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>验活结果</span>
|
||||
<span>
|
||||
成功: {successCount} / 失败: {failedCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果列表 */}
|
||||
{results.size > 0 && (
|
||||
<div className="max-h-[400px] overflow-y-auto border rounded-md p-2 space-y-1">
|
||||
{resultsArray.map((result) => (
|
||||
<div
|
||||
key={result.id}
|
||||
className={`text-sm p-2 rounded ${
|
||||
result.status === 'success'
|
||||
? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300'
|
||||
: result.status === 'failed'
|
||||
? 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'
|
||||
: result.status === 'verifying'
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
|
||||
: 'bg-gray-50 text-gray-700 dark:bg-gray-950 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">凭据 #{result.id}</span>
|
||||
{result.status === 'success' && result.usage && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{result.usage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span>
|
||||
{result.status === 'success' && '✓'}
|
||||
{result.status === 'failed' && '✗'}
|
||||
{result.status === 'verifying' && '⏳'}
|
||||
{result.status === 'pending' && '⋯'}
|
||||
</span>
|
||||
</div>
|
||||
{result.error && (
|
||||
<div className="text-xs mt-1 opacity-90">
|
||||
错误: {result.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示信息 */}
|
||||
{verifying && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 验活过程中每次请求间隔 2 秒,防止被封号。你可以关闭此窗口,验活会在后台继续进行。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{verifying ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
后台运行
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消验活
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
476
admin-ui/src/components/credential-card.tsx
Normal file
476
admin-ui/src/components/credential-card.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { RefreshCw, ChevronUp, ChevronDown, Wallet, Trash2, Loader2 } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { CredentialStatusItem, CachedBalanceInfo, BalanceResponse } from '@/types/api'
|
||||
import {
|
||||
useSetDisabled,
|
||||
useSetPriority,
|
||||
useSetRegion,
|
||||
useResetFailure,
|
||||
useDeleteCredential,
|
||||
} from '@/hooks/use-credentials'
|
||||
|
||||
interface CredentialCardProps {
|
||||
credential: CredentialStatusItem
|
||||
cachedBalance?: CachedBalanceInfo
|
||||
onViewBalance: (id: number, forceRefresh: boolean) => void
|
||||
selected: boolean
|
||||
onToggleSelect: () => void
|
||||
balance: BalanceResponse | null
|
||||
loadingBalance: boolean
|
||||
}
|
||||
|
||||
function formatLastUsed(lastUsedAt: string | null): string {
|
||||
if (!lastUsedAt) return '从未使用'
|
||||
const date = new Date(lastUsedAt)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
if (diff < 0) return '刚刚'
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return `${seconds} 秒前`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes} 分钟前`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} 天前`
|
||||
}
|
||||
|
||||
export function CredentialCard({
|
||||
credential,
|
||||
cachedBalance,
|
||||
onViewBalance,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
balance,
|
||||
loadingBalance,
|
||||
}: CredentialCardProps) {
|
||||
const [editingPriority, setEditingPriority] = useState(false)
|
||||
const [priorityValue, setPriorityValue] = useState(String(credential.priority))
|
||||
const [editingRegion, setEditingRegion] = useState(false)
|
||||
const [regionValue, setRegionValue] = useState(credential.region ?? '')
|
||||
const [apiRegionValue, setApiRegionValue] = useState(credential.apiRegion ?? '')
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
|
||||
const setDisabled = useSetDisabled()
|
||||
const setPriority = useSetPriority()
|
||||
const setRegion = useSetRegion()
|
||||
const resetFailure = useResetFailure()
|
||||
const deleteCredential = useDeleteCredential()
|
||||
|
||||
const handleToggleDisabled = () => {
|
||||
setDisabled.mutate(
|
||||
{ id: credential.id, disabled: !credential.disabled },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('操作失败: ' + (err as Error).message)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handlePriorityChange = () => {
|
||||
const newPriority = parseInt(priorityValue, 10)
|
||||
if (isNaN(newPriority) || newPriority < 0) {
|
||||
toast.error('优先级必须是非负整数')
|
||||
return
|
||||
}
|
||||
setPriority.mutate(
|
||||
{ id: credential.id, priority: newPriority },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message)
|
||||
setEditingPriority(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('操作失败: ' + (err as Error).message)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleRegionChange = () => {
|
||||
setRegion.mutate(
|
||||
{
|
||||
id: credential.id,
|
||||
region: regionValue.trim() || null,
|
||||
apiRegion: apiRegionValue.trim() || null,
|
||||
},
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message)
|
||||
setEditingRegion(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('操作失败: ' + (err as Error).message)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
resetFailure.mutate(credential.id, {
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('操作失败: ' + (err as Error).message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!credential.disabled) {
|
||||
toast.error('请先禁用凭据再删除')
|
||||
setShowDeleteDialog(false)
|
||||
return
|
||||
}
|
||||
|
||||
deleteCredential.mutate(credential.id, {
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.message)
|
||||
setShowDeleteDialog(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('删除失败: ' + (err as Error).message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化缓存时间(相对时间)
|
||||
const formatCacheAge = (cachedAt: number) => {
|
||||
const now = Date.now()
|
||||
const diff = now - cachedAt
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return `${seconds}秒前`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
return `${Math.floor(minutes / 60)}小时前`
|
||||
}
|
||||
|
||||
// 检查缓存是否过期(使用后端返回的 TTL)
|
||||
const isCacheStale = () => {
|
||||
if (!cachedBalance) return true
|
||||
const ageMs = Date.now() - cachedBalance.cachedAt
|
||||
const ttlMs = (cachedBalance.ttlSecs ?? 60) * 1000
|
||||
return ageMs > ttlMs
|
||||
}
|
||||
|
||||
const handleViewBalance = () => {
|
||||
onViewBalance(credential.id, isCacheStale())
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onToggleSelect}
|
||||
/>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{credential.email || `凭据 #${credential.id}`}
|
||||
{credential.disabled && (
|
||||
<Badge variant="destructive">已禁用</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">启用</span>
|
||||
<Switch
|
||||
checked={!credential.disabled}
|
||||
onCheckedChange={handleToggleDisabled}
|
||||
disabled={setDisabled.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 信息网格 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">优先级:</span>
|
||||
{editingPriority ? (
|
||||
<div className="inline-flex items-center gap-1 ml-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={priorityValue}
|
||||
onChange={(e) => setPriorityValue(e.target.value)}
|
||||
className="w-16 h-7 text-sm"
|
||||
min="0"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handlePriorityChange}
|
||||
disabled={setPriority.isPending}
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => {
|
||||
setEditingPriority(false)
|
||||
setPriorityValue(String(credential.priority))
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:underline ml-1"
|
||||
onClick={() => setEditingPriority(true)}
|
||||
>
|
||||
{credential.priority}
|
||||
<span className="text-xs text-muted-foreground ml-1">(点击编辑)</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">失败次数:</span>
|
||||
<span className={credential.failureCount > 0 ? 'text-red-500 font-medium' : ''}>
|
||||
{credential.failureCount}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">订阅等级:</span>
|
||||
<span className="font-medium">
|
||||
{loadingBalance ? (
|
||||
<Loader2 className="inline w-3 h-3 animate-spin" />
|
||||
) : balance?.subscriptionTitle || '未知'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">成功次数:</span>
|
||||
<span className="font-medium">{credential.successCount}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">最后调用:</span>
|
||||
<span className="font-medium">{formatLastUsed(credential.lastUsedAt)}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">剩余用量:</span>
|
||||
{loadingBalance ? (
|
||||
<span className="text-sm ml-1">
|
||||
<Loader2 className="inline w-3 h-3 animate-spin" /> 加载中...
|
||||
</span>
|
||||
) : balance ? (
|
||||
<span className="font-medium ml-1">
|
||||
{balance.remaining.toFixed(2)} / {balance.usageLimit.toFixed(2)}
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
({(100 - balance.usagePercentage).toFixed(1)}% 剩余)
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground ml-1">未知</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">余额:</span>
|
||||
{cachedBalance && cachedBalance.ttlSecs > 0 ? (
|
||||
<>
|
||||
<span className={`font-medium ${cachedBalance.remaining > 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||
${cachedBalance.remaining.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
({formatCacheAge(cachedBalance.cachedAt)})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
{credential.hasProxy && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">代理:</span>
|
||||
<span className="font-medium">{credential.proxyUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Region 配置 */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Region:</span>
|
||||
{editingRegion ? (
|
||||
<div className="inline-flex items-center gap-1 ml-1 flex-wrap">
|
||||
<Input
|
||||
placeholder="Region(留空清除)"
|
||||
value={regionValue}
|
||||
onChange={(e) => setRegionValue(e.target.value)}
|
||||
className="w-32 h-7 text-sm"
|
||||
/>
|
||||
<Input
|
||||
placeholder="API Region(可选)"
|
||||
value={apiRegionValue}
|
||||
onChange={(e) => setApiRegionValue(e.target.value)}
|
||||
className="w-36 h-7 text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleRegionChange}
|
||||
disabled={setRegion.isPending}
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => {
|
||||
setEditingRegion(false)
|
||||
setRegionValue(credential.region ?? '')
|
||||
setApiRegionValue(credential.apiRegion ?? '')
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:underline ml-1"
|
||||
onClick={() => {
|
||||
setRegionValue(credential.region ?? '')
|
||||
setApiRegionValue(credential.apiRegion ?? '')
|
||||
setEditingRegion(true)
|
||||
}}
|
||||
>
|
||||
{credential.region || '全局默认'}
|
||||
{credential.apiRegion && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
/ API: {credential.apiRegion}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-1">(点击编辑)</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{credential.hasProfileArn && (
|
||||
<div className="col-span-2">
|
||||
<Badge variant="secondary">有 Profile ARN</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={resetFailure.isPending || credential.failureCount === 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
重置失败
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newPriority = Math.max(0, credential.priority - 1)
|
||||
setPriority.mutate(
|
||||
{ id: credential.id, priority: newPriority },
|
||||
{
|
||||
onSuccess: (res) => toast.success(res.message),
|
||||
onError: (err) => toast.error('操作失败: ' + (err as Error).message),
|
||||
}
|
||||
)
|
||||
}}
|
||||
disabled={setPriority.isPending || credential.priority === 0}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
提高优先级
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newPriority = credential.priority + 1
|
||||
setPriority.mutate(
|
||||
{ id: credential.id, priority: newPriority },
|
||||
{
|
||||
onSuccess: (res) => toast.success(res.message),
|
||||
onError: (err) => toast.error('操作失败: ' + (err as Error).message),
|
||||
}
|
||||
)
|
||||
}}
|
||||
disabled={setPriority.isPending}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
降低优先级
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleViewBalance}
|
||||
>
|
||||
<Wallet className="h-4 w-4 mr-1" />
|
||||
查看余额
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={!credential.disabled}
|
||||
title={!credential.disabled ? '需要先禁用凭据才能删除' : undefined}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除凭据</DialogTitle>
|
||||
<DialogDescription>
|
||||
您确定要删除凭据 #{credential.id} 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
disabled={deleteCredential.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteCredential.isPending || !credential.disabled}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
700
admin-ui/src/components/dashboard.tsx
Normal file
700
admin-ui/src/components/dashboard.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { RefreshCw, LogOut, Moon, Sun, Server, Plus, Upload, Trash2, RotateCcw, CheckCircle2 } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CredentialCard } from '@/components/credential-card'
|
||||
import { BalanceDialog } from '@/components/balance-dialog'
|
||||
import { AddCredentialDialog } from '@/components/add-credential-dialog'
|
||||
import { ImportTokenJsonDialog } from '@/components/import-token-json-dialog'
|
||||
import { BatchVerifyDialog, type VerifyResult } from '@/components/batch-verify-dialog'
|
||||
import { useCredentials, useCachedBalances, useDeleteCredential, useResetFailure } from '@/hooks/use-credentials'
|
||||
import { getCredentialBalance } from '@/api/credentials'
|
||||
import { extractErrorMessage } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { BalanceResponse } from '@/types/api'
|
||||
|
||||
interface DashboardProps {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function Dashboard({ onLogout }: DashboardProps) {
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<number | null>(null)
|
||||
const [balanceDialogOpen, setBalanceDialogOpen] = useState(false)
|
||||
const [forceRefreshBalance, setForceRefreshBalance] = useState(false)
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [verifyDialogOpen, setVerifyDialogOpen] = useState(false)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [verifyProgress, setVerifyProgress] = useState({ current: 0, total: 0 })
|
||||
const [verifyResults, setVerifyResults] = useState<Map<number, VerifyResult>>(new Map())
|
||||
const [balanceMap, setBalanceMap] = useState<Map<number, BalanceResponse>>(new Map())
|
||||
const [loadingBalanceIds, setLoadingBalanceIds] = useState<Set<number>>(new Set())
|
||||
const [queryingInfo, setQueryingInfo] = useState(false)
|
||||
const [queryInfoProgress, setQueryInfoProgress] = useState({ current: 0, total: 0 })
|
||||
const cancelVerifyRef = useRef(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 12
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading, error, refetch } = useCredentials()
|
||||
const { data: cachedBalancesData } = useCachedBalances()
|
||||
const { mutate: deleteCredential } = useDeleteCredential()
|
||||
const { mutate: resetFailure } = useResetFailure()
|
||||
|
||||
// 构建 id -> cachedBalance 的映射
|
||||
const cachedBalanceMap = new Map(
|
||||
cachedBalancesData?.balances.map((b) => [b.id, b]) ?? []
|
||||
)
|
||||
|
||||
// 计算分页
|
||||
const totalPages = Math.ceil((data?.credentials.length || 0) / itemsPerPage)
|
||||
const startIndex = (currentPage - 1) * itemsPerPage
|
||||
const endIndex = startIndex + itemsPerPage
|
||||
const currentCredentials = data?.credentials.slice(startIndex, endIndex) || []
|
||||
const disabledCredentialCount = data?.credentials.filter(credential => credential.disabled).length || 0
|
||||
const selectedDisabledCount = Array.from(selectedIds).filter(id => {
|
||||
const credential = data?.credentials.find(c => c.id === id)
|
||||
return Boolean(credential?.disabled)
|
||||
}).length
|
||||
|
||||
// 当凭据列表变化时重置到第一页
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [data?.credentials.length])
|
||||
|
||||
// 只保留当前仍存在的凭据缓存,避免删除后残留旧数据
|
||||
useEffect(() => {
|
||||
if (!data?.credentials) {
|
||||
setBalanceMap(new Map())
|
||||
setLoadingBalanceIds(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
const validIds = new Set(data.credentials.map(credential => credential.id))
|
||||
|
||||
setBalanceMap(prev => {
|
||||
const next = new Map<number, BalanceResponse>()
|
||||
prev.forEach((value, id) => {
|
||||
if (validIds.has(id)) {
|
||||
next.set(id, value)
|
||||
}
|
||||
})
|
||||
return next.size === prev.size ? prev : next
|
||||
})
|
||||
|
||||
setLoadingBalanceIds(prev => {
|
||||
if (prev.size === 0) {
|
||||
return prev
|
||||
}
|
||||
const next = new Set<number>()
|
||||
prev.forEach(id => {
|
||||
if (validIds.has(id)) {
|
||||
next.add(id)
|
||||
}
|
||||
})
|
||||
return next.size === prev.size ? prev : next
|
||||
})
|
||||
}, [data?.credentials])
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setDarkMode(!darkMode)
|
||||
document.documentElement.classList.toggle('dark')
|
||||
}
|
||||
|
||||
const handleViewBalance = (id: number, forceRefresh: boolean) => {
|
||||
setSelectedCredentialId(id)
|
||||
setForceRefreshBalance(forceRefresh)
|
||||
if (forceRefresh) {
|
||||
// 清除该凭据的余额缓存,强制重新获取
|
||||
queryClient.invalidateQueries({ queryKey: ['credential-balance', id] })
|
||||
}
|
||||
setBalanceDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch()
|
||||
toast.success('已刷新凭据列表')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
storage.removeApiKey()
|
||||
queryClient.clear()
|
||||
onLogout()
|
||||
}
|
||||
|
||||
// 选择管理
|
||||
const toggleSelect = (id: number) => {
|
||||
const newSelected = new Set(selectedIds)
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id)
|
||||
} else {
|
||||
newSelected.add(id)
|
||||
}
|
||||
setSelectedIds(newSelected)
|
||||
}
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
// 批量删除(仅删除已禁用项)
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.error('请先选择要删除的凭据')
|
||||
return
|
||||
}
|
||||
|
||||
const disabledIds = Array.from(selectedIds).filter(id => {
|
||||
const credential = data?.credentials.find(c => c.id === id)
|
||||
return Boolean(credential?.disabled)
|
||||
})
|
||||
|
||||
if (disabledIds.length === 0) {
|
||||
toast.error('选中的凭据中没有已禁用项')
|
||||
return
|
||||
}
|
||||
|
||||
const skippedCount = selectedIds.size - disabledIds.length
|
||||
const skippedText = skippedCount > 0 ? `(将跳过 ${skippedCount} 个未禁用凭据)` : ''
|
||||
|
||||
if (!confirm(`确定要删除 ${disabledIds.length} 个已禁用凭据吗?此操作无法撤销。${skippedText}`)) {
|
||||
return
|
||||
}
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const id of disabledIds) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
deleteCredential(id, {
|
||||
onSuccess: () => {
|
||||
successCount++
|
||||
resolve()
|
||||
},
|
||||
onError: (err) => {
|
||||
failCount++
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
// 错误已在 onError 中处理
|
||||
}
|
||||
}
|
||||
|
||||
const skippedResultText = skippedCount > 0 ? `,已跳过 ${skippedCount} 个未禁用凭据` : ''
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`成功删除 ${successCount} 个已禁用凭据${skippedResultText}`)
|
||||
} else {
|
||||
toast.warning(`删除已禁用凭据:成功 ${successCount} 个,失败 ${failCount} 个${skippedResultText}`)
|
||||
}
|
||||
|
||||
deselectAll()
|
||||
}
|
||||
|
||||
// 批量恢复异常
|
||||
const handleBatchResetFailure = async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.error('请先选择要恢复的凭据')
|
||||
return
|
||||
}
|
||||
|
||||
const failedIds = Array.from(selectedIds).filter(id => {
|
||||
const cred = data?.credentials.find(c => c.id === id)
|
||||
return cred && cred.failureCount > 0
|
||||
})
|
||||
|
||||
if (failedIds.length === 0) {
|
||||
toast.error('选中的凭据中没有失败的凭据')
|
||||
return
|
||||
}
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const id of failedIds) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
resetFailure(id, {
|
||||
onSuccess: () => {
|
||||
successCount++
|
||||
resolve()
|
||||
},
|
||||
onError: (err) => {
|
||||
failCount++
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
// 错误已在 onError 中处理
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`成功恢复 ${successCount} 个凭据`)
|
||||
} else {
|
||||
toast.warning(`成功 ${successCount} 个,失败 ${failCount} 个`)
|
||||
}
|
||||
|
||||
deselectAll()
|
||||
}
|
||||
|
||||
// 一键清除所有已禁用凭据
|
||||
const handleClearAll = async () => {
|
||||
if (!data?.credentials || data.credentials.length === 0) {
|
||||
toast.error('没有可清除的凭据')
|
||||
return
|
||||
}
|
||||
|
||||
const disabledCredentials = data.credentials.filter(credential => credential.disabled)
|
||||
|
||||
if (disabledCredentials.length === 0) {
|
||||
toast.error('没有可清除的已禁用凭据')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`确定要清除所有 ${disabledCredentials.length} 个已禁用凭据吗?此操作无法撤销。`)) {
|
||||
return
|
||||
}
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const credential of disabledCredentials) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
deleteCredential(credential.id, {
|
||||
onSuccess: () => {
|
||||
successCount++
|
||||
resolve()
|
||||
},
|
||||
onError: (err) => {
|
||||
failCount++
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
// 错误已在 onError 中处理
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`成功清除所有 ${successCount} 个已禁用凭据`)
|
||||
} else {
|
||||
toast.warning(`清除已禁用凭据:成功 ${successCount} 个,失败 ${failCount} 个`)
|
||||
}
|
||||
|
||||
deselectAll()
|
||||
}
|
||||
|
||||
// 查询当前页凭据信息(逐个查询,避免瞬时并发)
|
||||
const handleQueryCurrentPageInfo = async () => {
|
||||
if (currentCredentials.length === 0) {
|
||||
toast.error('当前页没有可查询的凭据')
|
||||
return
|
||||
}
|
||||
|
||||
const ids = currentCredentials
|
||||
.filter(credential => !credential.disabled)
|
||||
.map(credential => credential.id)
|
||||
|
||||
if (ids.length === 0) {
|
||||
toast.error('当前页没有可查询的启用凭据')
|
||||
return
|
||||
}
|
||||
|
||||
setQueryingInfo(true)
|
||||
setQueryInfoProgress({ current: 0, total: ids.length })
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const id = ids[i]
|
||||
|
||||
setLoadingBalanceIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
const balance = await getCredentialBalance(id)
|
||||
successCount++
|
||||
|
||||
setBalanceMap(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(id, balance)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
failCount++
|
||||
} finally {
|
||||
setLoadingBalanceIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
setQueryInfoProgress({ current: i + 1, total: ids.length })
|
||||
}
|
||||
|
||||
setQueryingInfo(false)
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`查询完成:成功 ${successCount}/${ids.length}`)
|
||||
} else {
|
||||
toast.warning(`查询完成:成功 ${successCount} 个,失败 ${failCount} 个`)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量验活
|
||||
const handleBatchVerify = async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.error('请先选择要验活的凭据')
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化状态
|
||||
setVerifying(true)
|
||||
cancelVerifyRef.current = false
|
||||
const ids = Array.from(selectedIds)
|
||||
setVerifyProgress({ current: 0, total: ids.length })
|
||||
|
||||
let successCount = 0
|
||||
|
||||
// 初始化结果,所有凭据状态为 pending
|
||||
const initialResults = new Map<number, VerifyResult>()
|
||||
ids.forEach(id => {
|
||||
initialResults.set(id, { id, status: 'pending' })
|
||||
})
|
||||
setVerifyResults(initialResults)
|
||||
setVerifyDialogOpen(true)
|
||||
|
||||
// 开始验活
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
// 检查是否取消
|
||||
if (cancelVerifyRef.current) {
|
||||
toast.info('已取消验活')
|
||||
break
|
||||
}
|
||||
|
||||
const id = ids[i]
|
||||
|
||||
// 更新当前凭据状态为 verifying
|
||||
setVerifyResults(prev => {
|
||||
const newResults = new Map(prev)
|
||||
newResults.set(id, { id, status: 'verifying' })
|
||||
return newResults
|
||||
})
|
||||
|
||||
try {
|
||||
const balance = await getCredentialBalance(id)
|
||||
successCount++
|
||||
|
||||
// 更新为成功状态
|
||||
setVerifyResults(prev => {
|
||||
const newResults = new Map(prev)
|
||||
newResults.set(id, {
|
||||
id,
|
||||
status: 'success',
|
||||
usage: `${balance.currentUsage}/${balance.usageLimit}`
|
||||
})
|
||||
return newResults
|
||||
})
|
||||
} catch (error) {
|
||||
// 更新为失败状态
|
||||
setVerifyResults(prev => {
|
||||
const newResults = new Map(prev)
|
||||
newResults.set(id, {
|
||||
id,
|
||||
status: 'failed',
|
||||
error: extractErrorMessage(error)
|
||||
})
|
||||
return newResults
|
||||
})
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
setVerifyProgress({ current: i + 1, total: ids.length })
|
||||
|
||||
// 添加延迟防止封号(最后一个不需要延迟)
|
||||
if (i < ids.length - 1 && !cancelVerifyRef.current) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
}
|
||||
|
||||
setVerifying(false)
|
||||
|
||||
if (!cancelVerifyRef.current) {
|
||||
toast.success(`验活完成:成功 ${successCount}/${ids.length}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 取消验活
|
||||
const handleCancelVerify = () => {
|
||||
cancelVerifyRef.current = true
|
||||
setVerifying(false)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-red-500 mb-4">加载失败</div>
|
||||
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={() => refetch()}>重试</Button>
|
||||
<Button variant="outline" onClick={handleLogout}>重新登录</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 顶部导航 */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-14 items-center justify-between px-4 md:px-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
<span className="font-semibold">Kiro Admin</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={toggleDarkMode}>
|
||||
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleLogout}>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容 */}
|
||||
<main className="container mx-auto px-4 md:px-8 py-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
凭据总数
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data?.total || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
可用凭据
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{data?.available || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 凭据列表 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-semibold">凭据管理</h2>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">已选择 {selectedIds.size} 个</Badge>
|
||||
<Button onClick={deselectAll} size="sm" variant="ghost">
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button onClick={handleBatchVerify} size="sm" variant="outline">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
批量验活
|
||||
</Button>
|
||||
<Button onClick={handleBatchResetFailure} size="sm" variant="outline">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
恢复异常
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchDelete}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={selectedDisabledCount === 0}
|
||||
title={selectedDisabledCount === 0 ? '只能删除已禁用凭据' : undefined}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{verifying && !verifyDialogOpen && (
|
||||
<Button onClick={() => setVerifyDialogOpen(true)} size="sm" variant="secondary">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
验活中... {verifyProgress.current}/{verifyProgress.total}
|
||||
</Button>
|
||||
)}
|
||||
{data?.credentials && data.credentials.length > 0 && (
|
||||
<Button
|
||||
onClick={handleQueryCurrentPageInfo}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={queryingInfo}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${queryingInfo ? 'animate-spin' : ''}`} />
|
||||
{queryingInfo ? `查询中... ${queryInfoProgress.current}/${queryInfoProgress.total}` : '查询信息'}
|
||||
</Button>
|
||||
)}
|
||||
{data?.credentials && data.credentials.length > 0 && (
|
||||
<Button
|
||||
onClick={handleClearAll}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={disabledCredentialCount === 0}
|
||||
title={disabledCredentialCount === 0 ? '没有可清除的已禁用凭据' : undefined}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
清除已禁用
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setImportDialogOpen(true)} size="sm">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
导入凭据
|
||||
</Button>
|
||||
<Button onClick={() => setAddDialogOpen(true)} size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加凭据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{data?.credentials.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
暂无凭据
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{currentCredentials.map((credential) => (
|
||||
<CredentialCard
|
||||
key={credential.id}
|
||||
credential={credential}
|
||||
cachedBalance={cachedBalanceMap.get(credential.id)}
|
||||
onViewBalance={handleViewBalance}
|
||||
selected={selectedIds.has(credential.id)}
|
||||
onToggleSelect={() => toggleSelect(credential.id)}
|
||||
balance={balanceMap.get(credential.id) || null}
|
||||
loadingBalance={loadingBalanceIds.has(credential.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-4 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
第 {currentPage} / {totalPages} 页(共 {data?.credentials.length} 个凭据)
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 余额对话框 */}
|
||||
<BalanceDialog
|
||||
credentialId={selectedCredentialId}
|
||||
open={balanceDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBalanceDialogOpen(open)
|
||||
if (!open) {
|
||||
setForceRefreshBalance(false)
|
||||
// 关闭弹窗时刷新缓存余额,让卡片显示最新数据
|
||||
queryClient.invalidateQueries({ queryKey: ['cached-balances'] })
|
||||
}
|
||||
}}
|
||||
forceRefresh={forceRefreshBalance}
|
||||
/>
|
||||
|
||||
{/* 添加凭据对话框 */}
|
||||
<AddCredentialDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
/>
|
||||
|
||||
{/* 导入凭据对话框 */}
|
||||
<ImportTokenJsonDialog
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
/>
|
||||
|
||||
|
||||
{/* 批量验活对话框 */}
|
||||
<BatchVerifyDialog
|
||||
open={verifyDialogOpen}
|
||||
onOpenChange={setVerifyDialogOpen}
|
||||
verifying={verifying}
|
||||
progress={verifyProgress}
|
||||
results={verifyResults}
|
||||
onCancel={handleCancelVerify}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
671
admin-ui/src/components/import-token-json-dialog.tsx
Normal file
671
admin-ui/src/components/import-token-json-dialog.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Upload, FileJson, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useImportTokenJson, useDeleteCredential } from '@/hooks/use-credentials'
|
||||
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
|
||||
import { extractErrorMessage } from '@/lib/utils'
|
||||
import type { TokenJsonItem, ImportItemResult, ImportSummary } from '@/types/api'
|
||||
|
||||
interface ImportTokenJsonDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type Step = 'input' | 'preview' | 'result' | 'verifying'
|
||||
|
||||
// 验活结果
|
||||
interface VerifyItemResult {
|
||||
index: number
|
||||
credentialId?: number
|
||||
status: 'pending' | 'verifying' | 'verified' | 'failed' | 'skipped' | 'rolled_back' | 'rollback_failed'
|
||||
usage?: string
|
||||
error?: string
|
||||
rollbackError?: string
|
||||
}
|
||||
|
||||
export function ImportTokenJsonDialog({ open, onOpenChange }: ImportTokenJsonDialogProps) {
|
||||
const [step, setStep] = useState<Step>('input')
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [parsedItems, setParsedItems] = useState<TokenJsonItem[]>([])
|
||||
const [previewResults, setPreviewResults] = useState<ImportItemResult[]>([])
|
||||
const [previewSummary, setPreviewSummary] = useState<ImportSummary | null>(null)
|
||||
const [finalResults, setFinalResults] = useState<ImportItemResult[]>([])
|
||||
const [finalSummary, setFinalSummary] = useState<ImportSummary | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [enableVerify, setEnableVerify] = useState(false)
|
||||
const [verifyResults, setVerifyResults] = useState<VerifyItemResult[]>([])
|
||||
const [verifyProgress, setVerifyProgress] = useState({ current: 0, total: 0 })
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { mutate: importMutate, isPending } = useImportTokenJson()
|
||||
const { mutateAsync: deleteCredential } = useDeleteCredential()
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setStep('input')
|
||||
setJsonText('')
|
||||
setParsedItems([])
|
||||
setPreviewResults([])
|
||||
setPreviewSummary(null)
|
||||
setFinalResults([])
|
||||
setFinalSummary(null)
|
||||
setEnableVerify(false)
|
||||
setVerifyResults([])
|
||||
setVerifyProgress({ current: 0, total: 0 })
|
||||
setIsVerifying(false)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isVerifying) return // 验活中不允许关闭
|
||||
onOpenChange(false)
|
||||
setTimeout(resetState, 200)
|
||||
}, [onOpenChange, resetState, isVerifying])
|
||||
|
||||
// 将 KAM 账号结构展平为 TokenJsonItem
|
||||
const flattenKamAccount = useCallback((account: Record<string, unknown>): TokenJsonItem | null => {
|
||||
const cred = account.credentials as Record<string, unknown> | undefined
|
||||
if (!cred || typeof cred !== 'object') return null
|
||||
// refreshToken 必须是非空字符串
|
||||
if (typeof cred.refreshToken !== 'string' || !cred.refreshToken.trim()) return null
|
||||
// 跳过 error 状态的账号
|
||||
if (account.status === 'error') return null
|
||||
const authMethod = cred.authMethod as string | undefined
|
||||
return {
|
||||
refreshToken: cred.refreshToken.trim(),
|
||||
clientId: cred.clientId as string | undefined,
|
||||
clientSecret: cred.clientSecret as string | undefined,
|
||||
authMethod: (!authMethod && cred.clientId && cred.clientSecret) ? 'idc' : authMethod,
|
||||
region: cred.region as string | undefined,
|
||||
machineId: account.machineId as string | undefined,
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 解析 JSON(兼容 Token JSON / KAM 导出 / 批量导入格式)
|
||||
const parseJson = useCallback((text: string): TokenJsonItem[] | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
|
||||
let rawItems: unknown[]
|
||||
|
||||
// KAM 标准导出格式:{ version, accounts: [...] }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.accounts)) {
|
||||
rawItems = parsed.accounts
|
||||
} else if (Array.isArray(parsed)) {
|
||||
rawItems = parsed
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
rawItems = [parsed]
|
||||
} else {
|
||||
toast.error('JSON 格式无效')
|
||||
return null
|
||||
}
|
||||
|
||||
const validItems: TokenJsonItem[] = []
|
||||
for (const item of rawItems) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const obj = item as Record<string, unknown>
|
||||
|
||||
// KAM 嵌套格式:{ credentials: { refreshToken, ... } }
|
||||
if (obj.credentials && typeof obj.credentials === 'object') {
|
||||
const flat = flattenKamAccount(obj)
|
||||
if (flat) validItems.push(flat)
|
||||
continue
|
||||
}
|
||||
|
||||
// 扁平格式:{ refreshToken, ... }
|
||||
if (typeof obj.refreshToken === 'string' && obj.refreshToken.trim()) {
|
||||
const tokenItem = { ...obj, refreshToken: obj.refreshToken.trim() } as TokenJsonItem
|
||||
// 兼容旧批量导入的 authRegion 字段
|
||||
if (!tokenItem.region && obj.authRegion) {
|
||||
tokenItem.region = obj.authRegion as string
|
||||
}
|
||||
if (!tokenItem.authMethod && tokenItem.clientId && tokenItem.clientSecret) {
|
||||
tokenItem.authMethod = 'idc'
|
||||
}
|
||||
validItems.push(tokenItem)
|
||||
}
|
||||
}
|
||||
|
||||
if (validItems.length === 0) {
|
||||
toast.error('JSON 中没有找到有效的凭据(需要包含 refreshToken 字段)')
|
||||
return null
|
||||
}
|
||||
return validItems
|
||||
} catch {
|
||||
toast.error('JSON 格式无效')
|
||||
return null
|
||||
}
|
||||
}, [flattenKamAccount])
|
||||
|
||||
// 文件拖放
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (!file) return
|
||||
if (!file.name.endsWith('.json')) {
|
||||
toast.error('请上传 JSON 文件')
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => setJsonText(event.target?.result as string)
|
||||
reader.readAsText(file)
|
||||
}, [])
|
||||
|
||||
// 文件选择
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => setJsonText(event.target?.result as string)
|
||||
reader.readAsText(file)
|
||||
}, [])
|
||||
|
||||
// 预览(dry-run)
|
||||
const handlePreview = useCallback(() => {
|
||||
const items = parseJson(jsonText)
|
||||
if (!items) return
|
||||
setParsedItems(items)
|
||||
importMutate(
|
||||
{ dryRun: true, items },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setPreviewResults(response.items)
|
||||
setPreviewSummary(response.summary)
|
||||
setStep('preview')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`预览失败: ${extractErrorMessage(error)}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [jsonText, parseJson, importMutate])
|
||||
|
||||
// 回滚凭据(禁用 + 删除)
|
||||
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await setCredentialDisabled(id, true)
|
||||
} catch (error) {
|
||||
return { success: false, error: `禁用失败: ${extractErrorMessage(error)}` }
|
||||
}
|
||||
try {
|
||||
await deleteCredential(id)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: `删除失败: ${extractErrorMessage(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
// 验活流程
|
||||
const runVerification = useCallback(async (results: ImportItemResult[]) => {
|
||||
const addedItems = results.filter(r => r.action === 'added' && r.credentialId)
|
||||
if (addedItems.length === 0) {
|
||||
toast.info('没有新增凭据需要验活')
|
||||
return
|
||||
}
|
||||
|
||||
setIsVerifying(true)
|
||||
setStep('verifying')
|
||||
setVerifyProgress({ current: 0, total: addedItems.length })
|
||||
|
||||
const initialVerifyResults: VerifyItemResult[] = addedItems.map(item => ({
|
||||
index: item.index,
|
||||
credentialId: item.credentialId,
|
||||
status: 'pending',
|
||||
}))
|
||||
setVerifyResults(initialVerifyResults)
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (let i = 0; i < addedItems.length; i++) {
|
||||
const item = addedItems[i]
|
||||
const credId = item.credentialId!
|
||||
|
||||
// 更新为验活中
|
||||
setVerifyResults(prev => prev.map((r, idx) =>
|
||||
idx === i ? { ...r, status: 'verifying' } : r
|
||||
))
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
const balance = await getCredentialBalance(credId)
|
||||
successCount++
|
||||
setVerifyResults(prev => prev.map((r, idx) =>
|
||||
idx === i ? { ...r, status: 'verified', usage: `${balance.currentUsage}/${balance.usageLimit}` } : r
|
||||
))
|
||||
} catch (error) {
|
||||
failCount++
|
||||
// 验活失败,回滚
|
||||
const rollback = await rollbackCredential(credId)
|
||||
setVerifyResults(prev => prev.map((r, idx) =>
|
||||
idx === i ? {
|
||||
...r,
|
||||
status: rollback.success ? 'rolled_back' : 'rollback_failed',
|
||||
error: extractErrorMessage(error),
|
||||
rollbackError: rollback.error,
|
||||
} : r
|
||||
))
|
||||
}
|
||||
|
||||
setVerifyProgress({ current: i + 1, total: addedItems.length })
|
||||
}
|
||||
|
||||
setIsVerifying(false)
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`全部 ${successCount} 个凭据验活成功`)
|
||||
} else {
|
||||
toast.info(`验活完成:成功 ${successCount},失败 ${failCount}`)
|
||||
}
|
||||
}, [deleteCredential])
|
||||
|
||||
// 确认导入
|
||||
const handleConfirmImport = useCallback(() => {
|
||||
importMutate(
|
||||
{ dryRun: false, items: parsedItems },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setFinalResults(response.items)
|
||||
setFinalSummary(response.summary)
|
||||
|
||||
if (enableVerify) {
|
||||
// 开启验活模式:导入后自动验活
|
||||
if (response.summary.added > 0) {
|
||||
toast.success(`成功导入 ${response.summary.added} 个凭据,开始验活...`)
|
||||
runVerification(response.items)
|
||||
} else {
|
||||
// 没有新增凭据,直接显示结果
|
||||
setStep('result')
|
||||
toast.info('没有新增凭据需要验活')
|
||||
}
|
||||
} else {
|
||||
// 普通模式:直接显示结果
|
||||
setStep('result')
|
||||
if (response.summary.added > 0) {
|
||||
toast.success(`成功导入 ${response.summary.added} 个凭据`)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`导入失败: ${extractErrorMessage(error)}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [parsedItems, importMutate, enableVerify, runVerification])
|
||||
|
||||
// 渲染图标
|
||||
const renderActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case 'added': return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
case 'skipped': return <AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
case 'invalid': return <XCircle className="h-4 w-4 text-red-500" />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
const renderActionText = (action: string) => {
|
||||
switch (action) {
|
||||
case 'added': return <span className="text-green-600">添加</span>
|
||||
case 'skipped': return <span className="text-yellow-600">跳过</span>
|
||||
case 'invalid': return <span className="text-red-600">无效</span>
|
||||
default: return action
|
||||
}
|
||||
}
|
||||
|
||||
const getVerifyStatusIcon = (status: VerifyItemResult['status']) => {
|
||||
switch (status) {
|
||||
case 'pending': return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
|
||||
case 'verifying': return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
||||
case 'verified': return <CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
case 'failed':
|
||||
case 'rollback_failed': return <XCircle className="w-5 h-5 text-red-500" />
|
||||
case 'rolled_back': return <AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
case 'skipped': return <AlertCircle className="w-5 h-5 text-gray-400" />
|
||||
}
|
||||
}
|
||||
|
||||
const getVerifyStatusText = (result: VerifyItemResult) => {
|
||||
switch (result.status) {
|
||||
case 'pending': return '等待中'
|
||||
case 'verifying': return '验活中...'
|
||||
case 'verified': return '验活成功'
|
||||
case 'failed': return '验活失败'
|
||||
case 'rolled_back': return '验活失败(已排除)'
|
||||
case 'rollback_failed': return '验活失败(未排除)'
|
||||
case 'skipped': return '跳过'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileJson className="h-5 w-5" />
|
||||
导入凭据
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'input' && '粘贴或上传 JSON 文件以批量导入凭据'}
|
||||
{step === 'preview' && '预览导入结果,确认后执行导入'}
|
||||
{step === 'result' && '导入完成'}
|
||||
{step === 'verifying' && '正在验活导入的凭据...'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto py-4">
|
||||
{/* Step 1: Input */}
|
||||
{step === 'input' && (
|
||||
<div className="space-y-4">
|
||||
{/* 拖放区域 */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-10 w-10 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
拖放 JSON 文件到此处,或点击选择文件
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持单个凭据或凭据数组格式
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">或</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文本输入 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">直接粘贴 JSON</label>
|
||||
<textarea
|
||||
className="w-full h-48 p-3 text-sm font-mono border rounded-md bg-background resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder='{"refreshToken": "...", "provider": "BuilderId", ...}'
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Preview */}
|
||||
{step === 'preview' && previewSummary && (
|
||||
<div className="space-y-4">
|
||||
{/* 统计 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-muted rounded-lg">
|
||||
<div className="text-2xl font-bold">{previewSummary.parsed}</div>
|
||||
<div className="text-xs text-muted-foreground">解析</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 dark:bg-green-950 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">{previewSummary.added}</div>
|
||||
<div className="text-xs text-muted-foreground">将添加</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-600">{previewSummary.skipped}</div>
|
||||
<div className="text-xs text-muted-foreground">跳过</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-red-50 dark:bg-red-950 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">{previewSummary.invalid}</div>
|
||||
<div className="text-xs text-muted-foreground">无效</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览列表 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium">#</th>
|
||||
<th className="text-left p-2 font-medium">指纹</th>
|
||||
<th className="text-left p-2 font-medium">状态</th>
|
||||
<th className="text-left p-2 font-medium">原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewResults.map((item) => (
|
||||
<tr key={item.index} className="border-t">
|
||||
<td className="p-2">{item.index + 1}</td>
|
||||
<td className="p-2 font-mono text-xs">{item.fingerprint}</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{renderActionIcon(item.action)}
|
||||
{renderActionText(item.action)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground text-xs">
|
||||
{item.reason || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验活开关 */}
|
||||
{previewSummary.added > 0 && (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div>
|
||||
<div className="text-sm font-medium">导入后自动验活</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
逐个检查凭据有效性,失败的自动排除
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enableVerify} onCheckedChange={setEnableVerify} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Result (普通模式) */}
|
||||
{step === 'result' && finalSummary && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-muted rounded-lg">
|
||||
<div className="text-2xl font-bold">{finalSummary.parsed}</div>
|
||||
<div className="text-xs text-muted-foreground">解析</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 dark:bg-green-950 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">{finalSummary.added}</div>
|
||||
<div className="text-xs text-muted-foreground">已添加</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-600">{finalSummary.skipped}</div>
|
||||
<div className="text-xs text-muted-foreground">跳过</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-red-50 dark:bg-red-950 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">{finalSummary.invalid}</div>
|
||||
<div className="text-xs text-muted-foreground">无效</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="max-h-64 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium">#</th>
|
||||
<th className="text-left p-2 font-medium">指纹</th>
|
||||
<th className="text-left p-2 font-medium">状态</th>
|
||||
<th className="text-left p-2 font-medium">凭据 ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{finalResults.map((item) => (
|
||||
<tr key={item.index} className="border-t">
|
||||
<td className="p-2">{item.index + 1}</td>
|
||||
<td className="p-2 font-mono text-xs">{item.fingerprint}</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{renderActionIcon(item.action)}
|
||||
{renderActionText(item.action)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{item.credentialId ? `#${item.credentialId}` : item.reason || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Verifying (验活模式) */}
|
||||
{step === 'verifying' && (
|
||||
<div className="space-y-4">
|
||||
{/* 进度条 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{isVerifying ? '验活进度' : '验活完成'}</span>
|
||||
<span>{verifyProgress.current} / {verifyProgress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: verifyProgress.total > 0 ? `${(verifyProgress.current / verifyProgress.total) * 100}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计 */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
✓ 成功: {verifyResults.filter(r => r.status === 'verified').length}
|
||||
</span>
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
⚠ 已排除: {verifyResults.filter(r => r.status === 'rolled_back').length}
|
||||
</span>
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
✗ 失败: {verifyResults.filter(r => r.status === 'rollback_failed').length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
|
||||
{verifyResults.map((result) => (
|
||||
<div key={result.index} className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{getVerifyStatusIcon(result.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
凭据 #{result.credentialId || result.index + 1}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getVerifyStatusText(result)}
|
||||
</span>
|
||||
</div>
|
||||
{result.usage && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
用量: {result.usage}
|
||||
</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
{result.rollbackError && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
回滚失败: {result.rollbackError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{step === 'input' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handlePreview} disabled={!jsonText.trim() || isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
解析中...
|
||||
</>
|
||||
) : (
|
||||
'预览'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'preview' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setStep('input')}>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmImport}
|
||||
disabled={isPending || (previewSummary?.added ?? 0) === 0}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
导入中...
|
||||
</>
|
||||
) : enableVerify ? (
|
||||
`导入并验活 (${previewSummary?.added ?? 0})`
|
||||
) : (
|
||||
`确认导入 (${previewSummary?.added ?? 0})`
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'result' && (
|
||||
<Button onClick={handleClose}>完成</Button>
|
||||
)}
|
||||
|
||||
{step === 'verifying' && (
|
||||
<Button onClick={handleClose} disabled={isVerifying}>
|
||||
{isVerifying ? '验活中...' : '完成'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
491
admin-ui/src/components/kam-import-dialog.tsx
Normal file
491
admin-ui/src/components/kam-import-dialog.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useCredentials, useAddCredential, useDeleteCredential } from '@/hooks/use-credentials'
|
||||
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
|
||||
import { extractErrorMessage, sha256Hex } from '@/lib/utils'
|
||||
|
||||
interface KamImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// KAM 导出 JSON 中的账号结构
|
||||
interface KamAccount {
|
||||
email?: string
|
||||
userId?: string | null
|
||||
nickname?: string
|
||||
credentials: {
|
||||
refreshToken: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
region?: string
|
||||
authMethod?: string
|
||||
startUrl?: string
|
||||
}
|
||||
machineId?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
interface VerificationResult {
|
||||
index: number
|
||||
status: 'pending' | 'checking' | 'verifying' | 'verified' | 'duplicate' | 'failed' | 'skipped'
|
||||
error?: string
|
||||
usage?: string
|
||||
email?: string
|
||||
credentialId?: number
|
||||
rollbackStatus?: 'success' | 'failed' | 'skipped'
|
||||
rollbackError?: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 校验元素是否为有效的 KAM 账号结构
|
||||
function isValidKamAccount(item: unknown): item is KamAccount {
|
||||
if (typeof item !== 'object' || item === null) return false
|
||||
const obj = item as Record<string, unknown>
|
||||
if (typeof obj.credentials !== 'object' || obj.credentials === null) return false
|
||||
const cred = obj.credentials as Record<string, unknown>
|
||||
return typeof cred.refreshToken === 'string' && cred.refreshToken.trim().length > 0
|
||||
}
|
||||
|
||||
// 解析 KAM 导出 JSON,支持单账号和多账号格式
|
||||
function parseKamJson(raw: string): KamAccount[] {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
let rawItems: unknown[]
|
||||
|
||||
// 标准 KAM 导出格式:{ version, accounts: [...] }
|
||||
if (parsed.accounts && Array.isArray(parsed.accounts)) {
|
||||
rawItems = parsed.accounts
|
||||
}
|
||||
// 兜底:如果直接是账号数组
|
||||
else if (Array.isArray(parsed)) {
|
||||
rawItems = parsed
|
||||
}
|
||||
// 单个账号对象(有 credentials 字段)
|
||||
else if (parsed.credentials && typeof parsed.credentials === 'object') {
|
||||
rawItems = [parsed]
|
||||
}
|
||||
else {
|
||||
throw new Error('无法识别的 KAM JSON 格式')
|
||||
}
|
||||
|
||||
const validAccounts = rawItems.filter(isValidKamAccount)
|
||||
|
||||
if (rawItems.length > 0 && validAccounts.length === 0) {
|
||||
throw new Error(`共 ${rawItems.length} 条记录,但均缺少有效的 credentials.refreshToken`)
|
||||
}
|
||||
|
||||
if (validAccounts.length < rawItems.length) {
|
||||
const skipped = rawItems.length - validAccounts.length
|
||||
console.warn(`KAM 导入:跳过 ${skipped} 条缺少有效 credentials.refreshToken 的记录`)
|
||||
}
|
||||
|
||||
return validAccounts
|
||||
}
|
||||
|
||||
export function KamImportDialog({ open, onOpenChange }: KamImportDialogProps) {
|
||||
const [jsonInput, setJsonInput] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [skipErrorAccounts, setSkipErrorAccounts] = useState(true)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
const [currentProcessing, setCurrentProcessing] = useState<string>('')
|
||||
const [results, setResults] = useState<VerificationResult[]>([])
|
||||
|
||||
const { data: existingCredentials } = useCredentials()
|
||||
const { mutateAsync: addCredential } = useAddCredential()
|
||||
const { mutateAsync: deleteCredential } = useDeleteCredential()
|
||||
|
||||
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await setCredentialDisabled(id, true)
|
||||
} catch (error) {
|
||||
return { success: false, error: `禁用失败: ${extractErrorMessage(error)}` }
|
||||
}
|
||||
try {
|
||||
await deleteCredential(id)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: `删除失败: ${extractErrorMessage(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setJsonInput('')
|
||||
setProgress({ current: 0, total: 0 })
|
||||
setCurrentProcessing('')
|
||||
setResults([])
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
// 先单独解析 JSON,给出精准的错误提示
|
||||
let validAccounts: KamAccount[]
|
||||
try {
|
||||
const accounts = parseKamJson(jsonInput)
|
||||
|
||||
if (accounts.length === 0) {
|
||||
toast.error('没有可导入的账号')
|
||||
return
|
||||
}
|
||||
|
||||
validAccounts = accounts.filter(a => a.credentials?.refreshToken)
|
||||
if (validAccounts.length === 0) {
|
||||
toast.error('没有包含有效 refreshToken 的账号')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('JSON 格式错误: ' + extractErrorMessage(error))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
setImporting(true)
|
||||
setProgress({ current: 0, total: validAccounts.length })
|
||||
|
||||
// 初始化结果,标记 error 状态的账号
|
||||
const initialResults: VerificationResult[] = validAccounts.map((account, i) => {
|
||||
if (skipErrorAccounts && account.status === 'error') {
|
||||
return { index: i + 1, status: 'skipped' as const, email: account.email || account.nickname }
|
||||
}
|
||||
return { index: i + 1, status: 'pending' as const, email: account.email || account.nickname }
|
||||
})
|
||||
setResults(initialResults)
|
||||
|
||||
// 重复检测
|
||||
const existingTokenHashes = new Set(
|
||||
existingCredentials?.credentials
|
||||
.map(c => c.refreshTokenHash)
|
||||
.filter((hash): hash is string => Boolean(hash)) || []
|
||||
)
|
||||
|
||||
let successCount = 0
|
||||
let duplicateCount = 0
|
||||
let failCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (let i = 0; i < validAccounts.length; i++) {
|
||||
const account = validAccounts[i]
|
||||
|
||||
// 跳过 error 状态的账号
|
||||
if (skipErrorAccounts && account.status === 'error') {
|
||||
skippedCount++
|
||||
setProgress({ current: i + 1, total: validAccounts.length })
|
||||
continue
|
||||
}
|
||||
|
||||
const cred = account.credentials
|
||||
const token = cred.refreshToken.trim()
|
||||
const tokenHash = await sha256Hex(token)
|
||||
|
||||
setCurrentProcessing(`正在处理 ${account.email || account.nickname || `账号 ${i + 1}`}`)
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], status: 'checking' }
|
||||
return next
|
||||
})
|
||||
|
||||
// 检查重复
|
||||
if (existingTokenHashes.has(tokenHash)) {
|
||||
duplicateCount++
|
||||
const existingCred = existingCredentials?.credentials.find(c => c.refreshTokenHash === tokenHash)
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], status: 'duplicate', error: '该凭据已存在', email: existingCred?.email || account.email }
|
||||
return next
|
||||
})
|
||||
setProgress({ current: i + 1, total: validAccounts.length })
|
||||
continue
|
||||
}
|
||||
|
||||
// 验活中
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = { ...next[i], status: 'verifying' }
|
||||
return next
|
||||
})
|
||||
|
||||
let addedCredId: number | null = null
|
||||
|
||||
try {
|
||||
const clientId = cred.clientId?.trim() || undefined
|
||||
const clientSecret = cred.clientSecret?.trim() || undefined
|
||||
const authMethod = clientId && clientSecret ? 'idc' : 'social'
|
||||
|
||||
// idc 模式下必须同时提供 clientId 和 clientSecret
|
||||
if (authMethod === 'social' && (clientId || clientSecret)) {
|
||||
throw new Error('idc 模式需要同时提供 clientId 和 clientSecret')
|
||||
}
|
||||
|
||||
const addedCred = await addCredential({
|
||||
refreshToken: token,
|
||||
authMethod,
|
||||
authRegion: cred.region?.trim() || undefined,
|
||||
clientId,
|
||||
clientSecret,
|
||||
machineId: account.machineId?.trim() || undefined,
|
||||
})
|
||||
|
||||
addedCredId = addedCred.credentialId
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
const balance = await getCredentialBalance(addedCred.credentialId)
|
||||
|
||||
successCount++
|
||||
existingTokenHashes.add(tokenHash)
|
||||
setCurrentProcessing(`验活成功: ${addedCred.email || account.email || `账号 ${i + 1}`}`)
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = {
|
||||
...next[i],
|
||||
status: 'verified',
|
||||
usage: `${balance.currentUsage}/${balance.usageLimit}`,
|
||||
email: addedCred.email || account.email,
|
||||
credentialId: addedCred.credentialId,
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
let rollbackStatus: VerificationResult['rollbackStatus'] = 'skipped'
|
||||
let rollbackError: string | undefined
|
||||
|
||||
if (addedCredId) {
|
||||
const result = await rollbackCredential(addedCredId)
|
||||
if (result.success) {
|
||||
rollbackStatus = 'success'
|
||||
} else {
|
||||
rollbackStatus = 'failed'
|
||||
rollbackError = result.error
|
||||
}
|
||||
}
|
||||
|
||||
failCount++
|
||||
setResults(prev => {
|
||||
const next = [...prev]
|
||||
next[i] = {
|
||||
...next[i],
|
||||
status: 'failed',
|
||||
error: extractErrorMessage(error),
|
||||
rollbackStatus,
|
||||
rollbackError,
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
setProgress({ current: i + 1, total: validAccounts.length })
|
||||
}
|
||||
|
||||
// 汇总
|
||||
const parts: string[] = []
|
||||
if (successCount > 0) parts.push(`成功 ${successCount}`)
|
||||
if (duplicateCount > 0) parts.push(`重复 ${duplicateCount}`)
|
||||
if (failCount > 0) parts.push(`失败 ${failCount}`)
|
||||
if (skippedCount > 0) parts.push(`跳过 ${skippedCount}`)
|
||||
|
||||
if (failCount === 0 && duplicateCount === 0 && skippedCount === 0) {
|
||||
toast.success(`成功导入并验活 ${successCount} 个凭据`)
|
||||
} else {
|
||||
toast.info(`导入完成:${parts.join(',')}`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('导入失败: ' + extractErrorMessage(error))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: VerificationResult['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
|
||||
case 'checking':
|
||||
case 'verifying':
|
||||
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
||||
case 'verified':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
case 'duplicate':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
case 'skipped':
|
||||
return <AlertCircle className="w-5 h-5 text-gray-400" />
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (result: VerificationResult) => {
|
||||
switch (result.status) {
|
||||
case 'pending': return '等待中'
|
||||
case 'checking': return '检查重复...'
|
||||
case 'verifying': return '验活中...'
|
||||
case 'verified': return '验活成功'
|
||||
case 'duplicate': return '重复凭据'
|
||||
case 'skipped': return '已跳过(error 状态)'
|
||||
case 'failed':
|
||||
if (result.rollbackStatus === 'success') return '验活失败(已排除)'
|
||||
if (result.rollbackStatus === 'failed') return '验活失败(未排除)'
|
||||
return '验活失败(未创建)'
|
||||
}
|
||||
}
|
||||
|
||||
// 预览解析结果
|
||||
const { previewAccounts, parseError } = useMemo(() => {
|
||||
if (!jsonInput.trim()) return { previewAccounts: [] as KamAccount[], parseError: '' }
|
||||
try {
|
||||
return { previewAccounts: parseKamJson(jsonInput), parseError: '' }
|
||||
} catch (e) {
|
||||
return { previewAccounts: [] as KamAccount[], parseError: extractErrorMessage(e) }
|
||||
}
|
||||
}, [jsonInput])
|
||||
|
||||
const errorAccountCount = previewAccounts.filter(a => a.status === 'error').length
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!newOpen && importing) return
|
||||
if (!newOpen) resetForm()
|
||||
onOpenChange(newOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>KAM 账号导入(自动验活)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">KAM 导出 JSON</label>
|
||||
<textarea
|
||||
placeholder={'粘贴 Kiro Account Manager 导出的 JSON,格式如下:\n{\n "version": "1.5.0",\n "accounts": [\n {\n "email": "...",\n "credentials": {\n "refreshToken": "...",\n "clientId": "...",\n "clientSecret": "...",\n "region": "us-east-1"\n }\n }\n ]\n}'}
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
disabled={importing}
|
||||
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 解析预览 */}
|
||||
{parseError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400">解析失败: {parseError}</div>
|
||||
)}
|
||||
{previewAccounts.length > 0 && !importing && results.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
识别到 {previewAccounts.length} 个账号
|
||||
{errorAccountCount > 0 && `(其中 ${errorAccountCount} 个为 error 状态)`}
|
||||
</div>
|
||||
{errorAccountCount > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipErrorAccounts}
|
||||
onChange={(e) => setSkipErrorAccounts(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
跳过 error 状态的账号
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入进度和结果 */}
|
||||
{(importing || results.length > 0) && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{importing ? '导入进度' : '导入完成'}</span>
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{importing && currentProcessing && (
|
||||
<div className="text-xs text-muted-foreground">{currentProcessing}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
✓ 成功: {results.filter(r => r.status === 'verified').length}
|
||||
</span>
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
⚠ 重复: {results.filter(r => r.status === 'duplicate').length}
|
||||
</span>
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
✗ 失败: {results.filter(r => r.status === 'failed').length}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
○ 跳过: {results.filter(r => r.status === 'skipped').length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
|
||||
{results.map((result) => (
|
||||
<div key={result.index} className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{getStatusIcon(result.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{result.email || `账号 #${result.index}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getStatusText(result)}
|
||||
</span>
|
||||
</div>
|
||||
{result.usage && (
|
||||
<div className="text-xs text-muted-foreground mt-1">用量: {result.usage}</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">{result.error}</div>
|
||||
)}
|
||||
{result.rollbackError && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">回滚失败: {result.rollbackError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { onOpenChange(false); resetForm() }}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? '导入中...' : results.length > 0 ? '关闭' : '取消'}
|
||||
</Button>
|
||||
{results.length === 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonInput.trim() || previewAccounts.length === 0 || !!parseError}
|
||||
>
|
||||
开始导入并验活
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
62
admin-ui/src/components/login-page.tsx
Normal file
62
admin-ui/src/components/login-page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { KeyRound } from 'lucide-react'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (apiKey: string) => void
|
||||
}
|
||||
|
||||
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// 从 storage 读取保存的 API Key
|
||||
const savedKey = storage.getApiKey()
|
||||
if (savedKey) {
|
||||
setApiKey(savedKey)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (apiKey.trim()) {
|
||||
storage.setApiKey(apiKey.trim())
|
||||
onLogin(apiKey.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<KeyRound className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Kiro Admin</CardTitle>
|
||||
<CardDescription>
|
||||
请输入 Admin API Key 以访问管理面板
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Admin API Key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={!apiKey.trim()}>
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
admin-ui/src/components/ui/badge.tsx
Normal file
39
admin-ui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success:
|
||||
'border-transparent bg-green-500 text-white hover:bg-green-500/80',
|
||||
warning:
|
||||
'border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
55
admin-ui/src/components/ui/button.tsx
Normal file
55
admin-ui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
78
admin-ui/src/components/ui/card.tsx
Normal file
78
admin-ui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
28
admin-ui/src/components/ui/checkbox.tsx
Normal file
28
admin-ui/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
119
admin-ui/src/components/ui/dialog.tsx
Normal file
119
admin-ui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">关闭</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
24
admin-ui/src/components/ui/input.tsx
Normal file
24
admin-ui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
35
admin-ui/src/components/ui/progress.tsx
Normal file
35
admin-ui/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, ...props }, ref) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all',
|
||||
percentage > 80 ? 'bg-red-500' : percentage > 60 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Progress.displayName = 'Progress'
|
||||
|
||||
export { Progress }
|
||||
25
admin-ui/src/components/ui/sonner.tsx
Normal file
25
admin-ui/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Toaster as Sonner } from 'sonner'
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
26
admin-ui/src/components/ui/switch.tsx
Normal file
26
admin-ui/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
175
admin-ui/src/hooks/use-credentials.ts
Normal file
175
admin-ui/src/hooks/use-credentials.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getCredentials,
|
||||
deleteCredential,
|
||||
setCredentialDisabled,
|
||||
setCredentialPriority,
|
||||
setCredentialRegion,
|
||||
resetCredentialFailure,
|
||||
getCredentialBalance,
|
||||
getCachedBalances,
|
||||
getCredentialAccountInfo,
|
||||
addCredential,
|
||||
getCredentialStats,
|
||||
resetCredentialStats,
|
||||
resetAllStats,
|
||||
importTokenJson,
|
||||
} from '@/api/credentials'
|
||||
import type { AddCredentialRequest, ImportTokenJsonRequest } from '@/types/api'
|
||||
|
||||
// 查询凭据列表
|
||||
export function useCredentials() {
|
||||
return useQuery({
|
||||
queryKey: ['credentials'],
|
||||
queryFn: getCredentials,
|
||||
refetchInterval: 30000, // 每 30 秒刷新一次
|
||||
})
|
||||
}
|
||||
|
||||
// 查询凭据余额
|
||||
export function useCredentialBalance(id: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ['credential-balance', id],
|
||||
queryFn: () => getCredentialBalance(id!),
|
||||
enabled: id !== null,
|
||||
retry: false, // 余额查询失败时不重试(避免重复请求被封禁的账号)
|
||||
})
|
||||
}
|
||||
|
||||
// 查询所有凭据的缓存余额(定时轮询,带退避策略)
|
||||
export function useCachedBalances() {
|
||||
return useQuery({
|
||||
queryKey: ['cached-balances'],
|
||||
queryFn: getCachedBalances,
|
||||
refetchInterval: (query) => (query.state.error ? 60000 : 30000),
|
||||
refetchIntervalInBackground: false, // 页面不可见时暂停轮询
|
||||
})
|
||||
}
|
||||
|
||||
// 查询凭据账号信息(套餐/用量/邮箱等)
|
||||
export function useCredentialAccountInfo(id: number | null, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['credential-account', id],
|
||||
queryFn: () => getCredentialAccountInfo(id!),
|
||||
enabled: enabled && id !== null,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
// 删除指定凭据
|
||||
export function useDeleteCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteCredential(id),
|
||||
onSuccess: (_res, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['credential-balance', id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['credential-account', id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['credential-stats', id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 设置禁用状态
|
||||
export function useSetDisabled() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, disabled }: { id: number; disabled: boolean }) =>
|
||||
setCredentialDisabled(id, disabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 设置优先级
|
||||
export function useSetPriority() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, priority }: { id: number; priority: number }) =>
|
||||
setCredentialPriority(id, priority),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 设置 Region
|
||||
export function useSetRegion() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, region, apiRegion }: { id: number; region: string | null; apiRegion: string | null }) =>
|
||||
setCredentialRegion(id, region, apiRegion),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 重置失败计数
|
||||
export function useResetFailure() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => resetCredentialFailure(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 添加新凭据
|
||||
export function useAddCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (req: AddCredentialRequest) => addCredential(req),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 查询指定凭据统计
|
||||
export function useCredentialStats(id: number | null, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['credential-stats', id],
|
||||
queryFn: () => getCredentialStats(id!),
|
||||
enabled: enabled && id !== null,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
// 清空指定凭据统计
|
||||
export function useResetCredentialStats() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => resetCredentialStats(id),
|
||||
onSuccess: (_res, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['credential-stats', id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 清空全部统计
|
||||
export function useResetAllStats() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => resetAllStats(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['credential-stats'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 批量导入 token.json
|
||||
export function useImportTokenJson() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (req: ImportTokenJsonRequest) => importTokenJson(req),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cached-balances'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
90
admin-ui/src/index.css
Normal file
90
admin-ui/src/index.css
Normal file
@@ -0,0 +1,90 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 自定义主题配置 */
|
||||
@theme {
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
44
admin-ui/src/lib/format.ts
Normal file
44
admin-ui/src/lib/format.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export function formatCompactNumber(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '-'
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
|
||||
const abs = Math.abs(value)
|
||||
const sign = value < 0 ? '-' : ''
|
||||
|
||||
const formatScaled = (scale: number, suffix: string) => {
|
||||
const scaled = abs / scale
|
||||
const decimals = scaled < 10 ? 1 : 0
|
||||
const fixed = scaled.toFixed(decimals)
|
||||
const trimmed = fixed.endsWith('.0') ? fixed.slice(0, -2) : fixed
|
||||
return `${sign}${trimmed}${suffix}`
|
||||
}
|
||||
|
||||
if (abs >= 1_000_000_000) return formatScaled(1_000_000_000, 'B')
|
||||
if (abs >= 1_000_000) return formatScaled(1_000_000, 'M')
|
||||
if (abs >= 1_000) return formatScaled(1_000, 'K')
|
||||
|
||||
// 小于 1000:按整数显示
|
||||
return `${sign}${Math.round(abs)}`
|
||||
}
|
||||
|
||||
export function formatTokensPair(inputTokens: number, outputTokens: number): string {
|
||||
return `${formatCompactNumber(inputTokens)} in / ${formatCompactNumber(outputTokens)} out`
|
||||
}
|
||||
|
||||
export function formatExpiry(expiresAt: string | null): string {
|
||||
if (!expiresAt) return '未知'
|
||||
const date = new Date(expiresAt)
|
||||
if (isNaN(date.getTime())) return expiresAt
|
||||
|
||||
const now = new Date()
|
||||
const diff = date.getTime() - now.getTime()
|
||||
if (diff < 0) return '已过期'
|
||||
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 60) return `${minutes} 分钟`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时`
|
||||
|
||||
return `${Math.floor(hours / 24)} 天`
|
||||
}
|
||||
7
admin-ui/src/lib/storage.ts
Normal file
7
admin-ui/src/lib/storage.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const API_KEY_STORAGE_KEY = 'adminApiKey'
|
||||
|
||||
export const storage = {
|
||||
getApiKey: () => localStorage.getItem(API_KEY_STORAGE_KEY),
|
||||
setApiKey: (key: string) => localStorage.setItem(API_KEY_STORAGE_KEY, key),
|
||||
removeApiKey: () => localStorage.removeItem(API_KEY_STORAGE_KEY),
|
||||
}
|
||||
202
admin-ui/src/lib/utils.ts
Normal file
202
admin-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析后端错误响应,提取用户友好的错误信息
|
||||
*/
|
||||
export interface ParsedError {
|
||||
/** 简短的错误标题 */
|
||||
title: string
|
||||
/** 详细的错误描述 */
|
||||
detail?: string
|
||||
/** 错误类型 */
|
||||
type?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误对象中提取错误消息
|
||||
* 支持 Axios 错误和普通 Error 对象
|
||||
*/
|
||||
export function extractErrorMessage(error: unknown): string {
|
||||
const parsed = parseError(error)
|
||||
return parsed.title
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析错误,返回结构化的错误信息
|
||||
*/
|
||||
export function parseError(error: unknown): ParsedError {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return { title: '未知错误' }
|
||||
}
|
||||
|
||||
const axiosError = error as Record<string, unknown>
|
||||
const response = axiosError.response as Record<string, unknown> | undefined
|
||||
const data = response?.data as Record<string, unknown> | undefined
|
||||
const errorObj = data?.error as Record<string, unknown> | undefined
|
||||
|
||||
// 尝试从后端错误响应中提取信息
|
||||
if (errorObj && typeof errorObj.message === 'string') {
|
||||
const message = errorObj.message
|
||||
const type = typeof errorObj.type === 'string' ? errorObj.type : undefined
|
||||
|
||||
// 解析嵌套的错误信息(如:上游服务错误: 权限不足: 403 {...})
|
||||
const parsed = parseNestedErrorMessage(message)
|
||||
|
||||
return {
|
||||
title: parsed.title,
|
||||
detail: parsed.detail,
|
||||
type,
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 Error.message
|
||||
if ('message' in axiosError && typeof axiosError.message === 'string') {
|
||||
return { title: axiosError.message }
|
||||
}
|
||||
|
||||
return { title: '未知错误' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析嵌套的错误消息
|
||||
* 例如:"上游服务错误: 权限不足,无法获取使用额度: 403 Forbidden {...}"
|
||||
*/
|
||||
function parseNestedErrorMessage(message: string): { title: string; detail?: string } {
|
||||
// 尝试提取 HTTP 状态码(如 403、502 等)
|
||||
const statusMatch = message.match(/(\d{3})\s+\w+/)
|
||||
const statusCode = statusMatch ? statusMatch[1] : null
|
||||
|
||||
// 尝试提取 JSON 中的 message 字段
|
||||
const jsonMatch = message.match(/\{[^{}]*"message"\s*:\s*"([^"]+)"[^{}]*\}/)
|
||||
if (jsonMatch) {
|
||||
const innerMessage = jsonMatch[1]
|
||||
// 提取主要错误原因(去掉前缀)
|
||||
const parts = message.split(':').map(s => s.trim())
|
||||
const mainReason = parts.length > 1 ? parts[1].split(':')[0] : parts[0]
|
||||
|
||||
// 在 title 中包含状态码
|
||||
const title = statusCode
|
||||
? `${mainReason || '服务错误'} (${statusCode})`
|
||||
: (mainReason || '服务错误')
|
||||
|
||||
return {
|
||||
title,
|
||||
detail: innerMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试按冒号分割,提取主要信息
|
||||
const colonParts = message.split(':')
|
||||
if (colonParts.length >= 2) {
|
||||
const mainPart = colonParts[1].trim().split(':')[0].trim()
|
||||
const title = statusCode ? `${mainPart} (${statusCode})` : mainPart
|
||||
|
||||
return {
|
||||
title,
|
||||
detail: colonParts.slice(2).join(':').trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return { title: message }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 计算字符串的 SHA-256 哈希(十六进制)
|
||||
*
|
||||
* 优先使用 Web Crypto API(crypto.subtle),在非安全上下文(HTTP + 非 localhost)中
|
||||
* 自动回退到纯 JS 实现,解决 Docker 部署时 crypto.subtle 不可用的问题。
|
||||
*/
|
||||
export async function sha256Hex(value: string): Promise<string> {
|
||||
const encoded = new TextEncoder().encode(value)
|
||||
|
||||
// 安全上下文中使用原生 Web Crypto API(性能更好)
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
try {
|
||||
const digest = await crypto.subtle.digest('SHA-256', encoded)
|
||||
const bytes = new Uint8Array(digest)
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
} catch {
|
||||
// digest 可能在某些策略/上下文下抛错,回退到纯 JS 实现
|
||||
}
|
||||
}
|
||||
|
||||
// 非安全上下文 fallback:纯 JS SHA-256 实现
|
||||
return sha256Pure(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯 JS SHA-256 实现(无外部依赖)
|
||||
* 仅在 crypto.subtle 不可用时使用
|
||||
*/
|
||||
function sha256Pure(data: Uint8Array): string {
|
||||
// SHA-256 常量:前 64 个素数的立方根的小数部分
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
])
|
||||
|
||||
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n))
|
||||
|
||||
// 预处理:填充消息
|
||||
const bitLen = data.length * 8
|
||||
// 消息 + 1 字节 0x80 + 填充 + 8 字节长度,总长度对齐到 64 字节
|
||||
const padLen = (((data.length + 9 + 63) >>> 6) << 6)
|
||||
const buf = new Uint8Array(padLen)
|
||||
buf.set(data)
|
||||
buf[data.length] = 0x80
|
||||
// 写入 64 位大端长度(仅低 32 位,高 32 位在 JS 中始终为 0)
|
||||
const view = new DataView(buf.buffer)
|
||||
view.setUint32(padLen - 4, bitLen, false)
|
||||
|
||||
// 初始哈希值
|
||||
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a
|
||||
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19
|
||||
|
||||
const w = new Uint32Array(64)
|
||||
|
||||
for (let offset = 0; offset < padLen; offset += 64) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
w[i] = view.getUint32(offset + i * 4, false)
|
||||
}
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)
|
||||
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0
|
||||
}
|
||||
|
||||
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
|
||||
const ch = (e & f) ^ (~e & g)
|
||||
const temp1 = (h + S1 + ch + K[i] + w[i]) | 0
|
||||
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c)
|
||||
const temp2 = (S0 + maj) | 0
|
||||
|
||||
h = g; g = f; f = e; e = (d + temp1) | 0
|
||||
d = c; c = b; b = a; a = (temp1 + temp2) | 0
|
||||
}
|
||||
|
||||
h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0
|
||||
h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0
|
||||
}
|
||||
|
||||
return [h0, h1, h2, h3, h4, h5, h6, h7]
|
||||
.map(v => (v >>> 0).toString(16).padStart(8, '0'))
|
||||
.join('')
|
||||
}
|
||||
22
admin-ui/src/main.tsx
Normal file
22
admin-ui/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
311
admin-ui/src/types/api.ts
Normal file
311
admin-ui/src/types/api.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
// 凭据状态响应
|
||||
export interface CredentialsStatusResponse {
|
||||
total: number
|
||||
available: number
|
||||
credentials: CredentialStatusItem[]
|
||||
}
|
||||
|
||||
// 单个凭据状态
|
||||
export interface CredentialStatusItem {
|
||||
id: number
|
||||
priority: number
|
||||
disabled: boolean
|
||||
failureCount: number
|
||||
expiresAt: string | null
|
||||
authMethod: string | null
|
||||
hasProfileArn: boolean
|
||||
accountEmail: string | null
|
||||
email?: string
|
||||
refreshTokenHash?: string
|
||||
|
||||
// ===== 统计(可持久化) =====
|
||||
callsTotal: number
|
||||
callsOk: number
|
||||
callsErr: number
|
||||
inputTokensTotal: number
|
||||
outputTokensTotal: number
|
||||
lastCallAt: string | null
|
||||
lastSuccessAt: string | null
|
||||
lastErrorAt: string | null
|
||||
lastError: string | null
|
||||
|
||||
// ===== upstream 字段 =====
|
||||
successCount: number
|
||||
lastUsedAt: string | null
|
||||
hasProxy: boolean
|
||||
proxyUrl?: string
|
||||
/** 凭据级 Region(用于 Token 刷新) */
|
||||
region: string | null
|
||||
/** 凭据级 API Region(单独覆盖 API 请求) */
|
||||
apiRegion: string | null
|
||||
}
|
||||
|
||||
// 余额响应
|
||||
export interface BalanceResponse {
|
||||
id: number
|
||||
subscriptionTitle: string | null
|
||||
currentUsage: number
|
||||
usageLimit: number
|
||||
remaining: number
|
||||
usagePercentage: number
|
||||
nextResetAt: number | null
|
||||
}
|
||||
|
||||
// 缓存余额信息
|
||||
export interface CachedBalanceInfo {
|
||||
id: number
|
||||
remaining: number
|
||||
cachedAt: number // Unix 毫秒时间戳
|
||||
ttlSecs: number
|
||||
}
|
||||
|
||||
// 缓存余额响应
|
||||
export interface CachedBalancesResponse {
|
||||
balances: CachedBalanceInfo[]
|
||||
}
|
||||
|
||||
// 成功响应
|
||||
export interface SuccessResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// ===== 统计(可持久化) =====
|
||||
|
||||
export interface StatsBucket {
|
||||
// 按日:YYYY-MM-DD;按模型:model id
|
||||
key: string
|
||||
callsTotal: number
|
||||
callsOk: number
|
||||
callsErr: number
|
||||
inputTokensTotal: number
|
||||
outputTokensTotal: number
|
||||
lastCallAt: string | null
|
||||
lastSuccessAt: string | null
|
||||
lastErrorAt: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
export interface CredentialStatsResponse {
|
||||
id: number
|
||||
callsTotal: number
|
||||
callsOk: number
|
||||
callsErr: number
|
||||
inputTokensTotal: number
|
||||
outputTokensTotal: number
|
||||
lastCallAt: string | null
|
||||
lastSuccessAt: string | null
|
||||
lastErrorAt: string | null
|
||||
lastError: string | null
|
||||
byDay: StatsBucket[]
|
||||
byModel: StatsBucket[]
|
||||
}
|
||||
|
||||
// 错误响应
|
||||
export interface AdminErrorResponse {
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
// 请求类型
|
||||
export interface SetDisabledRequest {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export interface SetPriorityRequest {
|
||||
priority: number
|
||||
}
|
||||
|
||||
// 添加凭据请求
|
||||
export interface AddCredentialRequest {
|
||||
refreshToken: string
|
||||
authMethod?: 'social' | 'idc'
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
priority?: number
|
||||
/** Region(用于 Token 刷新及默认 API 请求),可被 apiRegion 单独覆盖 */
|
||||
region?: string
|
||||
/** 单独覆盖 API 请求使用的 region */
|
||||
apiRegion?: string
|
||||
machineId?: string
|
||||
proxyUrl?: string
|
||||
proxyUsername?: string
|
||||
proxyPassword?: string
|
||||
}
|
||||
|
||||
// 添加凭据响应
|
||||
export interface AddCredentialResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
credentialId: number
|
||||
email?: string
|
||||
}
|
||||
|
||||
// ===== 账号信息(套餐/用量/邮箱等) =====
|
||||
|
||||
export interface CreditBonus {
|
||||
code: string
|
||||
name: string
|
||||
current: number
|
||||
limit: number
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
export interface CreditsResourceDetail {
|
||||
displayName: string | null
|
||||
displayNamePlural: string | null
|
||||
resourceType: string | null
|
||||
currency: string | null
|
||||
unit: string | null
|
||||
overageRate: number | null
|
||||
overageCap: number | null
|
||||
}
|
||||
|
||||
export interface CreditsUsageSummary {
|
||||
current: number
|
||||
limit: number
|
||||
baseCurrent: number
|
||||
baseLimit: number
|
||||
freeTrialCurrent: number
|
||||
freeTrialLimit: number
|
||||
freeTrialExpiry: string | null
|
||||
bonuses: CreditBonus[]
|
||||
nextResetDate: string | null
|
||||
overageEnabled: boolean | null
|
||||
resourceDetail: CreditsResourceDetail | null
|
||||
}
|
||||
|
||||
export interface AccountSubscriptionDetails {
|
||||
rawType: string | null
|
||||
managementTarget: string | null
|
||||
upgradeCapability: string | null
|
||||
overageCapability: string | null
|
||||
}
|
||||
|
||||
export interface ResourceUsageSummary {
|
||||
resourceType: string | null
|
||||
displayName: string | null
|
||||
unit: string | null
|
||||
currency: string | null
|
||||
current: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface UsageAndLimitsResponse {
|
||||
userInfo: { email: string | null; userId: string | null } | null
|
||||
subscriptionInfo:
|
||||
| {
|
||||
type: string | null
|
||||
subscriptionTitle: string | null
|
||||
upgradeCapability: string | null
|
||||
overageCapability: string | null
|
||||
subscriptionManagementTarget: string | null
|
||||
}
|
||||
| null
|
||||
usageBreakdownList:
|
||||
| Array<{
|
||||
resourceType: string | null
|
||||
currentUsage: number | null
|
||||
currentUsageWithPrecision: number | null
|
||||
usageLimit: number | null
|
||||
usageLimitWithPrecision: number | null
|
||||
displayName: string | null
|
||||
displayNamePlural: string | null
|
||||
currency: string | null
|
||||
unit: string | null
|
||||
overageRate: number | null
|
||||
overageCap: number | null
|
||||
freeTrialInfo:
|
||||
| {
|
||||
usageLimit: number | null
|
||||
usageLimitWithPrecision: number | null
|
||||
currentUsage: number | null
|
||||
currentUsageWithPrecision: number | null
|
||||
freeTrialExpiry: string | null
|
||||
freeTrialStatus: string | null
|
||||
}
|
||||
| null
|
||||
bonuses:
|
||||
| Array<{
|
||||
bonusCode: string | null
|
||||
displayName: string | null
|
||||
usageLimit: number | null
|
||||
usageLimitWithPrecision: number | null
|
||||
currentUsage: number | null
|
||||
currentUsageWithPrecision: number | null
|
||||
status: string | null
|
||||
expiresAt: string | null
|
||||
}>
|
||||
| null
|
||||
}>
|
||||
| null
|
||||
nextDateReset: string | null
|
||||
overageConfiguration: { overageEnabled: boolean | null } | null
|
||||
}
|
||||
|
||||
export interface AccountAggregateInfo {
|
||||
email: string | null
|
||||
userId: string | null
|
||||
idp: string | null
|
||||
status: string | null
|
||||
featureFlags: string[] | null
|
||||
subscriptionTitle: string | null
|
||||
subscriptionType: string
|
||||
subscription: AccountSubscriptionDetails
|
||||
usage: CreditsUsageSummary
|
||||
resources: ResourceUsageSummary[]
|
||||
rawUsage: UsageAndLimitsResponse
|
||||
}
|
||||
|
||||
export interface CredentialAccountInfoResponse {
|
||||
id: number
|
||||
account: AccountAggregateInfo
|
||||
}
|
||||
|
||||
// ============ 批量导入 token.json ============
|
||||
|
||||
// 官方 token.json 格式(用于解析导入)
|
||||
export interface TokenJsonItem {
|
||||
provider?: string
|
||||
refreshToken?: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
authMethod?: string
|
||||
priority?: number
|
||||
region?: string
|
||||
machineId?: string
|
||||
}
|
||||
|
||||
// 批量导入请求
|
||||
export interface ImportTokenJsonRequest {
|
||||
dryRun?: boolean
|
||||
items: TokenJsonItem | TokenJsonItem[]
|
||||
}
|
||||
|
||||
// 导入动作
|
||||
export type ImportAction = 'added' | 'skipped' | 'invalid'
|
||||
|
||||
// 单项导入结果
|
||||
export interface ImportItemResult {
|
||||
index: number
|
||||
fingerprint: string
|
||||
action: ImportAction
|
||||
reason?: string
|
||||
credentialId?: number
|
||||
}
|
||||
|
||||
// 导入汇总
|
||||
export interface ImportSummary {
|
||||
parsed: number
|
||||
added: number
|
||||
skipped: number
|
||||
invalid: number
|
||||
}
|
||||
|
||||
// 批量导入响应
|
||||
export interface ImportTokenJsonResponse {
|
||||
summary: ImportSummary
|
||||
items: ImportItemResult[]
|
||||
}
|
||||
53
admin-ui/tailwind.config.js
Normal file
53
admin-ui/tailwind.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
28
admin-ui/vite.config.ts
Normal file
28
admin-ui/vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/admin/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
kiro-rs:
|
||||
# 使用国内镜像
|
||||
# image: ghcr.nju.edu.cn/${IMAGE_OWNER:-hank9999}/kiro-rs:${IMAGE_TAG:-latest}
|
||||
image: ghcr.io/${IMAGE_OWNER:-hank9999}/kiro-rs:${IMAGE_TAG:-latest}
|
||||
container_name: kiro-rs
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "8990:8990"
|
||||
volumes:
|
||||
- ./config/:/app/config/
|
||||
restart: unless-stopped
|
||||
75
docs/media.md
Normal file
75
docs/media.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 多媒体输入(图片 / GIF / 视频)
|
||||
|
||||
本项目在 Anthropic → Kiro 的协议转换过程中,会把 `messages[].content` 里的 `image` 内容块转换为上游 Kiro API 的 `images[]` 字段。
|
||||
|
||||
为避免上游对“大请求体/异常输入”返回 `400 Improperly formed request`,对 GIF 做了专门处理(抽帧→静态图)。
|
||||
|
||||
## 图片(静态)
|
||||
|
||||
当前支持的静态图片 `source.media_type`:
|
||||
|
||||
- `image/jpeg`
|
||||
- `image/png`
|
||||
- `image/webp`
|
||||
|
||||
处理策略:
|
||||
|
||||
- 读取尺寸后按配置做等比缩放(长边/像素上限)
|
||||
- 保持原格式重编码(仅在需要缩放时)
|
||||
|
||||
## GIF(动图)
|
||||
|
||||
### 为什么不能直接透传 GIF
|
||||
|
||||
GIF 常见特征是「分辨率不大但字节体积巨大」(多帧 + 调色板 + 压缩特性),如果把整段 GIF base64 原样塞进上游请求体,极易触发上游的请求体大小/校验限制(表现为 `400 Improperly formed request` 这类兜底错误)。
|
||||
|
||||
### 当前实现:抽帧输出多张静态图
|
||||
|
||||
当检测到 `source.media_type=image/gif` 时,会:
|
||||
|
||||
1. 解码 GIF,计算总时长与源帧数
|
||||
2. 按时间轴采样(见下方采样策略)
|
||||
3. 对被选中的帧按同样的缩放规则处理,并重编码为静态 `jpeg`
|
||||
4. 将这些帧按时间顺序写入上游请求的 `images[]`
|
||||
|
||||
### 采样策略(固定上限,按时长自适应)
|
||||
|
||||
约束:
|
||||
|
||||
- 总输出帧数 `<= 20`
|
||||
- 采样频率 `<= 5 fps`
|
||||
|
||||
规则(等价描述):
|
||||
|
||||
- `fps = min(5, floor(20 / ceil(duration_seconds)))`
|
||||
- `interval_ms = 1000 / fps`
|
||||
- 若 `duration_seconds` 很大导致 `fps=0`,则按 `interval_ms = ceil(duration_ms / 20)` 做均匀采样
|
||||
|
||||
例子:
|
||||
|
||||
- 8 秒 GIF:`floor(20/8)=2` → `2 fps` → `interval=500ms` → 最多约 `16` 张
|
||||
- 4 秒 GIF:`floor(20/4)=5` → `5 fps` → `interval=200ms` → 最多 `20` 张
|
||||
|
||||
## 视频(mp4 / mov 等)
|
||||
|
||||
当前状态:**未实现**。
|
||||
|
||||
说明:
|
||||
|
||||
- Anthropic 的 `image` 内容块规范里通常不会发送 `video/*`,因此本项目当前也不会把 `video/mp4`、`video/quicktime` 等媒体类型转换成上游 `images[]`(等价于不支持/忽略)。
|
||||
|
||||
### 规划方案(记录,暂不实现)
|
||||
|
||||
如果未来需要支持“视频 → 多张静态图”的输入,推荐方案是引入视频解码能力(通常依赖 `ffmpeg/ffprobe`):
|
||||
|
||||
1. `ffprobe` 读取视频时长(毫秒)
|
||||
2. 复用 GIF 的采样策略计算 `interval_ms`,并限制 `<=20` 帧、`<=5fps`
|
||||
3. `ffmpeg` 按时间点抽帧(导出 `jpeg/webp`)
|
||||
4. 每帧按现有缩放规则处理后 base64 化,写入 `images[]`
|
||||
|
||||
建议配套的安全/稳定性约束(避免 DoS 与请求体爆炸):
|
||||
|
||||
- 限制输入视频最大字节数与最大时长(超限直接 400)
|
||||
- 限制抽帧后总输出图片字节数(超限则降低 fps/分辨率/质量,或回退更少帧)
|
||||
- 使用临时目录并确保清理;并发下避免落盘文件名冲突
|
||||
|
||||
BIN
docs/qrcode_1769645166806.png
Normal file
BIN
docs/qrcode_1769645166806.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
146
examples/test_compression.rs
Normal file
146
examples/test_compression.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let path = std::env::args().nth(1).ok_or_else(|| {
|
||||
anyhow::anyhow!("Usage: cargo run --example test_compression -- <path-to-json>")
|
||||
})?;
|
||||
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let data: Value = serde_json::from_str(&content)?;
|
||||
|
||||
let (req, raw_body_len) = if let Some(body_str) = data["request_body"].as_str() {
|
||||
(
|
||||
serde_json::from_str::<Value>(body_str)?,
|
||||
Some(body_str.len()),
|
||||
)
|
||||
} else {
|
||||
(data, None)
|
||||
};
|
||||
|
||||
let body_len = match raw_body_len {
|
||||
Some(n) => n,
|
||||
None => serde_json::to_string(&req).unwrap_or_default().len(),
|
||||
};
|
||||
println!(
|
||||
"原始请求体大小: {} bytes ({:.1} KB)",
|
||||
body_len,
|
||||
body_len as f64 / 1024.0
|
||||
);
|
||||
|
||||
if let Some(messages) = req["messages"].as_array() {
|
||||
println!("消息数量: {}", messages.len());
|
||||
|
||||
let mut user_count = 0;
|
||||
let mut assistant_count = 0;
|
||||
let mut total_chars = 0;
|
||||
let mut tool_result_chars = 0;
|
||||
let mut tool_use_chars = 0;
|
||||
|
||||
for msg in messages {
|
||||
match msg["role"].as_str() {
|
||||
Some("user") => user_count += 1,
|
||||
Some("assistant") => assistant_count += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(content) = msg["content"].as_array() {
|
||||
for item in content {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
total_chars += text.len();
|
||||
}
|
||||
// 统计 tool_result
|
||||
if item["type"].as_str() == Some("tool_result")
|
||||
&& let Some(result_content) = item["content"].as_array()
|
||||
{
|
||||
for result_item in result_content {
|
||||
if let Some(text) = result_item["text"].as_str() {
|
||||
tool_result_chars += text.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 统计 tool_use
|
||||
if item["type"].as_str() == Some("tool_use") {
|
||||
let input_str =
|
||||
serde_json::to_string(&item["input"]).unwrap_or_else(|_| "null".into());
|
||||
tool_use_chars += input_str.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(" - user: {}", user_count);
|
||||
println!(" - assistant: {}", assistant_count);
|
||||
println!(
|
||||
" - 文本字符数: {} ({:.1} KB)",
|
||||
total_chars,
|
||||
total_chars as f64 / 1024.0
|
||||
);
|
||||
println!(
|
||||
" - tool_result 字符数: {} ({:.1} KB)",
|
||||
tool_result_chars,
|
||||
tool_result_chars as f64 / 1024.0
|
||||
);
|
||||
println!(
|
||||
" - tool_use input 字符数: {} ({:.1} KB)",
|
||||
tool_use_chars,
|
||||
tool_use_chars as f64 / 1024.0
|
||||
);
|
||||
|
||||
// 模拟历史截断
|
||||
let max_history_turns = 80;
|
||||
let max_history_chars = 400_000;
|
||||
|
||||
let turns = messages.len() / 2;
|
||||
println!("\n压缩模拟(默认配置):");
|
||||
println!(" - 当前轮数: {} (阈值: {})", turns, max_history_turns);
|
||||
|
||||
if turns > max_history_turns {
|
||||
let to_remove = turns - max_history_turns;
|
||||
println!(" - 需要移除: {} 轮 ({} 条消息)", to_remove, to_remove * 2);
|
||||
} else {
|
||||
println!(" - 轮数未超限");
|
||||
}
|
||||
|
||||
let total_content_chars = total_chars + tool_result_chars + tool_use_chars;
|
||||
println!(
|
||||
" - 总内容字符数: {} ({:.1} KB)",
|
||||
total_content_chars,
|
||||
total_content_chars as f64 / 1024.0
|
||||
);
|
||||
|
||||
if total_content_chars > max_history_chars {
|
||||
println!(
|
||||
" - 字符数超限: {} > {}",
|
||||
total_content_chars, max_history_chars
|
||||
);
|
||||
} else {
|
||||
println!(" - 字符数未超限");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tools) = req["tools"].as_array() {
|
||||
let tools_str = serde_json::to_string(tools).unwrap_or_default();
|
||||
println!("\n工具数量: {}", tools.len());
|
||||
println!(
|
||||
"工具定义总大小: {} bytes ({:.1} KB)",
|
||||
tools_str.len(),
|
||||
tools_str.len() as f64 / 1024.0
|
||||
);
|
||||
|
||||
// 统计每个工具描述的大小
|
||||
let mut total_desc_chars = 0;
|
||||
for tool in tools {
|
||||
if let Some(desc) = tool["description"].as_str() {
|
||||
total_desc_chars += desc.len();
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"工具描述总字符数: {} ({:.1} KB)",
|
||||
total_desc_chars,
|
||||
total_desc_chars as f64 / 1024.0
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
64
src/admin/error.rs
Normal file
64
src/admin/error.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Admin API 错误类型定义
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use super::types::AdminErrorResponse;
|
||||
|
||||
/// Admin 服务错误类型
|
||||
#[derive(Debug)]
|
||||
pub enum AdminServiceError {
|
||||
/// 凭据不存在
|
||||
NotFound { id: u64 },
|
||||
|
||||
/// 上游服务调用失败(网络、API 错误等)
|
||||
UpstreamError(String),
|
||||
|
||||
/// 内部状态错误
|
||||
InternalError(String),
|
||||
|
||||
/// 凭据无效(验证失败)
|
||||
InvalidCredential(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for AdminServiceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
AdminServiceError::NotFound { id } => {
|
||||
write!(f, "凭据不存在: {}", id)
|
||||
}
|
||||
AdminServiceError::UpstreamError(msg) => write!(f, "上游服务错误: {}", msg),
|
||||
AdminServiceError::InternalError(msg) => write!(f, "内部错误: {}", msg),
|
||||
AdminServiceError::InvalidCredential(msg) => write!(f, "凭据无效: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AdminServiceError {}
|
||||
|
||||
impl AdminServiceError {
|
||||
/// 获取对应的 HTTP 状态码
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AdminServiceError::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
AdminServiceError::UpstreamError(_) => StatusCode::BAD_GATEWAY,
|
||||
AdminServiceError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminServiceError::InvalidCredential(_) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为 API 错误响应
|
||||
pub fn into_response(self) -> AdminErrorResponse {
|
||||
match &self {
|
||||
AdminServiceError::NotFound { .. } => AdminErrorResponse::not_found(self.to_string()),
|
||||
AdminServiceError::UpstreamError(_) => AdminErrorResponse::api_error(self.to_string()),
|
||||
AdminServiceError::InternalError(_) => {
|
||||
AdminErrorResponse::internal_error(self.to_string())
|
||||
}
|
||||
AdminServiceError::InvalidCredential(_) => {
|
||||
AdminErrorResponse::invalid_request(self.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/admin/handlers.rs
Normal file
139
src/admin/handlers.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! Admin API HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use super::{
|
||||
middleware::AdminState,
|
||||
types::{
|
||||
AddCredentialRequest, ImportTokenJsonRequest, SetDisabledRequest, SetPriorityRequest,
|
||||
SetRegionRequest, SuccessResponse,
|
||||
},
|
||||
};
|
||||
|
||||
/// GET /api/admin/credentials
|
||||
/// 获取所有凭据状态
|
||||
pub async fn get_all_credentials(State(state): State<AdminState>) -> impl IntoResponse {
|
||||
let response = state.service.get_all_credentials();
|
||||
Json(response)
|
||||
}
|
||||
|
||||
/// POST /api/admin/credentials/:id/disabled
|
||||
/// 设置凭据禁用状态
|
||||
pub async fn set_credential_disabled(
|
||||
State(state): State<AdminState>,
|
||||
Path(id): Path<u64>,
|
||||
Json(payload): Json<SetDisabledRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.service.set_disabled(id, payload.disabled) {
|
||||
Ok(_) => {
|
||||
let action = if payload.disabled { "禁用" } else { "启用" };
|
||||
Json(SuccessResponse::new(format!("凭据 #{} 已{}", id, action))).into_response()
|
||||
}
|
||||
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/credentials/:id/priority
|
||||
/// 设置凭据优先级
|
||||
pub async fn set_credential_priority(
|
||||
State(state): State<AdminState>,
|
||||
Path(id): Path<u64>,
|
||||
Json(payload): Json<SetPriorityRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.service.set_priority(id, payload.priority) {
|
||||
Ok(_) => Json(SuccessResponse::new(format!(
|
||||
"凭据 #{} 优先级已设置为 {}",
|
||||
id, payload.priority
|
||||
)))
|
||||
.into_response(),
|
||||
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/credentials/:id/region
|
||||
/// 设置凭据 Region
|
||||
pub async fn set_credential_region(
|
||||
State(state): State<AdminState>,
|
||||
Path(id): Path<u64>,
|
||||
Json(payload): Json<SetRegionRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.service
|
||||
.set_region(id, payload.region, payload.api_region)
|
||||
{
|
||||
Ok(_) => Json(SuccessResponse::new(format!("凭据 #{} Region 已更新", id))).into_response(),
|
||||
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/credentials/:id/reset
|
||||
/// 重置失败计数并重新启用
|
||||
pub async fn reset_failure_count(
|
||||
State(state): State<AdminState>,
|
||||
Path(id): Path<u64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.service.reset_and_enable(id) {
|
||||
Ok(_) => Json(SuccessResponse::new(format!(
|
||||
"凭据 #{} 失败计数已重置并重新启用",
|
||||
id
|
||||
)))
|
||||
.into_response(),
|
||||
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/admin/credentials/:id/balance
|
||||
/// 获取指定凭据的余额
|
||||
pub async fn get_credential_balance(
|
||||
State(state): State<AdminState>,
|
||||
Path(id): Path<u64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.service.get_balance(id).await {
|
||||
Ok(response) => Json(response).into_response(),
|
||||
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/admin/credentials/balances/cached
|
||||
/// 获取所有凭据的缓存余额
|
||||
pub async fn get_cached_balances(State(state): State<AdminState>) -> impl IntoResponse {
|
||||
Json(state.service.get_cached_balances())
|
||||
}
|
||||
|
||||
/// POST /api/admin/credentials
|
||||
/// 添加新凭据
|
||||
pub async fn add_credential(
|
||||
State(state): State<AdminState>,
|
||||
Json(payload): Json<AddCredentialRequest>,
|
||||
) -> impl IntoResponse {
|
||||
match state.service.add_credential(payload).await {
|
||||
Ok(response) => Json(response).into_response(),
|
||||
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/credentials/:id
|
||||
/// 删除凭据
|
||||
pub async fn delete_credential(
|
||||
State(state): State<AdminState>,
|
||||
Path(id): Path<u64>,
|
||||
) -> impl IntoResponse {
|
||||
match state.service.delete_credential(id) {
|
||||
Ok(_) => Json(SuccessResponse::new(format!("凭据 #{} 已删除", id))).into_response(),
|
||||
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/credentials/import-token-json
|
||||
/// 批量导入 token.json
|
||||
pub async fn import_token_json(
|
||||
State(state): State<AdminState>,
|
||||
Json(payload): Json<ImportTokenJsonRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let response = state.service.import_token_json(payload).await;
|
||||
Json(response)
|
||||
}
|
||||
50
src/admin/middleware.rs
Normal file
50
src/admin/middleware.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Admin API 中间件
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
|
||||
use super::service::AdminService;
|
||||
use super::types::AdminErrorResponse;
|
||||
use crate::common::auth;
|
||||
|
||||
/// Admin API 共享状态
|
||||
#[derive(Clone)]
|
||||
pub struct AdminState {
|
||||
/// Admin API 密钥
|
||||
pub admin_api_key: String,
|
||||
/// Admin 服务
|
||||
pub service: Arc<AdminService>,
|
||||
}
|
||||
|
||||
impl AdminState {
|
||||
pub fn new(admin_api_key: impl Into<String>, service: AdminService) -> Self {
|
||||
Self {
|
||||
admin_api_key: admin_api_key.into(),
|
||||
service: Arc::new(service),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin API 认证中间件
|
||||
pub async fn admin_auth_middleware(
|
||||
State(state): State<AdminState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let api_key = auth::extract_api_key(&request);
|
||||
|
||||
match api_key {
|
||||
Some(key) if auth::constant_time_eq(&key, &state.admin_api_key) => next.run(request).await,
|
||||
_ => {
|
||||
let error = AdminErrorResponse::authentication_error();
|
||||
(StatusCode::UNAUTHORIZED, Json(error)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/admin/mod.rs
Normal file
28
src/admin/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! Admin API 模块
|
||||
//!
|
||||
//! 提供凭据管理和监控功能的 HTTP API
|
||||
//!
|
||||
//! # 功能
|
||||
//! - 查询所有凭据状态
|
||||
//! - 启用/禁用凭据
|
||||
//! - 修改凭据优先级
|
||||
//! - 重置失败计数
|
||||
//! - 查询凭据余额
|
||||
//!
|
||||
//! # 使用
|
||||
//! ```ignore
|
||||
//! let admin_service = AdminService::new(token_manager.clone());
|
||||
//! let admin_state = AdminState::new(admin_api_key, admin_service);
|
||||
//! let admin_router = create_admin_router(admin_state);
|
||||
//! ```
|
||||
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod middleware;
|
||||
mod router;
|
||||
mod service;
|
||||
pub mod types;
|
||||
|
||||
pub use middleware::AdminState;
|
||||
pub use router::create_admin_router;
|
||||
pub use service::AdminService;
|
||||
53
src/admin/router.rs
Normal file
53
src/admin/router.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! Admin API 路由配置
|
||||
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
|
||||
use super::{
|
||||
handlers::{
|
||||
add_credential, delete_credential, get_all_credentials, get_cached_balances,
|
||||
get_credential_balance, import_token_json, reset_failure_count, set_credential_disabled,
|
||||
set_credential_priority, set_credential_region,
|
||||
},
|
||||
middleware::{AdminState, admin_auth_middleware},
|
||||
};
|
||||
|
||||
/// 创建 Admin API 路由
|
||||
///
|
||||
/// # 端点
|
||||
/// - `GET /credentials` - 获取所有凭据状态
|
||||
/// - `POST /credentials` - 添加新凭据
|
||||
/// - `POST /credentials/import-token-json` - 批量导入 token.json
|
||||
/// - `DELETE /credentials/:id` - 删除凭据
|
||||
/// - `POST /credentials/:id/disabled` - 设置凭据禁用状态
|
||||
/// - `POST /credentials/:id/priority` - 设置凭据优先级
|
||||
/// - `POST /credentials/:id/reset` - 重置失败计数
|
||||
/// - `GET /credentials/:id/balance` - 获取凭据余额
|
||||
/// - `GET /credentials/balances/cached` - 获取所有凭据的缓存余额
|
||||
///
|
||||
/// # 认证
|
||||
/// 需要 Admin API Key 认证,支持:
|
||||
/// - `x-api-key` header
|
||||
/// - `Authorization: Bearer <token>` header
|
||||
pub fn create_admin_router(state: AdminState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/credentials",
|
||||
get(get_all_credentials).post(add_credential),
|
||||
)
|
||||
.route("/credentials/balances/cached", get(get_cached_balances))
|
||||
.route("/credentials/import-token-json", post(import_token_json))
|
||||
.route("/credentials/{id}", delete(delete_credential))
|
||||
.route("/credentials/{id}/disabled", post(set_credential_disabled))
|
||||
.route("/credentials/{id}/priority", post(set_credential_priority))
|
||||
.route("/credentials/{id}/region", post(set_credential_region))
|
||||
.route("/credentials/{id}/reset", post(reset_failure_count))
|
||||
.route("/credentials/{id}/balance", get(get_credential_balance))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
admin_auth_middleware,
|
||||
))
|
||||
.with_state(state)
|
||||
}
|
||||
612
src/admin/service.rs
Normal file
612
src/admin/service.rs
Normal file
@@ -0,0 +1,612 @@
|
||||
//! Admin API 业务逻辑服务
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::common::utf8::floor_char_boundary;
|
||||
use crate::kiro::model::credentials::KiroCredentials;
|
||||
use crate::kiro::token_manager::MultiTokenManager;
|
||||
|
||||
use super::error::AdminServiceError;
|
||||
use super::types::{
|
||||
AddCredentialRequest, AddCredentialResponse, BalanceResponse, CachedBalanceItem,
|
||||
CachedBalancesResponse, CredentialStatusItem, CredentialsStatusResponse, ImportAction,
|
||||
ImportItemResult, ImportSummary, ImportTokenJsonRequest, ImportTokenJsonResponse,
|
||||
TokenJsonItem,
|
||||
};
|
||||
|
||||
/// 余额缓存过期时间(秒),5 分钟
|
||||
const BALANCE_CACHE_TTL_SECS: i64 = 300;
|
||||
|
||||
/// 缓存的余额条目(含时间戳)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CachedBalance {
|
||||
/// 缓存时间(Unix 秒)
|
||||
cached_at: f64,
|
||||
/// 缓存的余额数据
|
||||
data: BalanceResponse,
|
||||
}
|
||||
|
||||
/// Admin 服务
|
||||
///
|
||||
/// 封装所有 Admin API 的业务逻辑
|
||||
pub struct AdminService {
|
||||
token_manager: Arc<MultiTokenManager>,
|
||||
balance_cache: Mutex<HashMap<u64, CachedBalance>>,
|
||||
cache_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AdminService {
|
||||
pub fn new(token_manager: Arc<MultiTokenManager>) -> Self {
|
||||
let cache_path = token_manager
|
||||
.cache_dir()
|
||||
.map(|d| d.join("kiro_balance_cache.json"));
|
||||
|
||||
let balance_cache = Self::load_balance_cache_from(&cache_path);
|
||||
|
||||
Self {
|
||||
token_manager,
|
||||
balance_cache: Mutex::new(balance_cache),
|
||||
cache_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有凭据状态
|
||||
pub fn get_all_credentials(&self) -> CredentialsStatusResponse {
|
||||
let snapshot = self.token_manager.snapshot();
|
||||
|
||||
let mut credentials: Vec<CredentialStatusItem> = snapshot
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| CredentialStatusItem {
|
||||
id: entry.id,
|
||||
priority: entry.priority,
|
||||
disabled: entry.disabled,
|
||||
failure_count: entry.failure_count,
|
||||
expires_at: entry.expires_at,
|
||||
auth_method: entry.auth_method,
|
||||
has_profile_arn: entry.has_profile_arn,
|
||||
refresh_token_hash: entry.refresh_token_hash,
|
||||
email: entry.email,
|
||||
success_count: entry.success_count,
|
||||
last_used_at: entry.last_used_at.clone(),
|
||||
region: entry.region,
|
||||
api_region: entry.api_region,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 按优先级排序(数字越小优先级越高)
|
||||
credentials.sort_by_key(|c| c.priority);
|
||||
|
||||
CredentialsStatusResponse {
|
||||
total: snapshot.total,
|
||||
available: snapshot.available,
|
||||
credentials,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置凭据禁用状态
|
||||
pub fn set_disabled(&self, id: u64, disabled: bool) -> Result<(), AdminServiceError> {
|
||||
self.token_manager
|
||||
.set_disabled(id, disabled)
|
||||
.map_err(|e| self.classify_error(e, id))
|
||||
}
|
||||
|
||||
/// 设置凭据优先级
|
||||
pub fn set_priority(&self, id: u64, priority: u32) -> Result<(), AdminServiceError> {
|
||||
self.token_manager
|
||||
.set_priority(id, priority)
|
||||
.map_err(|e| self.classify_error(e, id))
|
||||
}
|
||||
|
||||
/// 设置凭据 Region
|
||||
pub fn set_region(
|
||||
&self,
|
||||
id: u64,
|
||||
region: Option<String>,
|
||||
api_region: Option<String>,
|
||||
) -> Result<(), AdminServiceError> {
|
||||
// trim 后空字符串转 None
|
||||
let region = region
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let api_region = api_region
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
self.token_manager
|
||||
.set_region(id, region, api_region)
|
||||
.map_err(|e| self.classify_error(e, id))
|
||||
}
|
||||
|
||||
/// 重置失败计数并重新启用
|
||||
pub fn reset_and_enable(&self, id: u64) -> Result<(), AdminServiceError> {
|
||||
self.token_manager
|
||||
.reset_and_enable(id)
|
||||
.map_err(|e| self.classify_error(e, id))
|
||||
}
|
||||
|
||||
/// 获取凭据余额(带缓存)
|
||||
pub async fn get_balance(&self, id: u64) -> Result<BalanceResponse, AdminServiceError> {
|
||||
// 先查缓存
|
||||
{
|
||||
let cache = self.balance_cache.lock();
|
||||
if let Some(cached) = cache.get(&id) {
|
||||
let now = Utc::now().timestamp() as f64;
|
||||
if (now - cached.cached_at) < BALANCE_CACHE_TTL_SECS as f64 {
|
||||
tracing::debug!("凭据 #{} 余额命中缓存", id);
|
||||
return Ok(cached.data.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中或已过期,从上游获取
|
||||
let balance = self.fetch_balance(id).await?;
|
||||
|
||||
// 更新缓存
|
||||
{
|
||||
let mut cache = self.balance_cache.lock();
|
||||
cache.insert(
|
||||
id,
|
||||
CachedBalance {
|
||||
cached_at: Utc::now().timestamp() as f64,
|
||||
data: balance.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.save_balance_cache();
|
||||
|
||||
Ok(balance)
|
||||
}
|
||||
|
||||
/// 从上游获取余额(无缓存)
|
||||
async fn fetch_balance(&self, id: u64) -> Result<BalanceResponse, AdminServiceError> {
|
||||
let usage = self
|
||||
.token_manager
|
||||
.get_usage_limits_for(id)
|
||||
.await
|
||||
.map_err(|e| self.classify_balance_error(e, id))?;
|
||||
|
||||
let current_usage = usage.current_usage();
|
||||
let usage_limit = usage.usage_limit();
|
||||
let remaining = (usage_limit - current_usage).max(0.0);
|
||||
let usage_percentage = if usage_limit > 0.0 {
|
||||
(current_usage / usage_limit * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// 更新缓存,使列表页面能显示最新余额
|
||||
self.token_manager.update_balance_cache(id, remaining);
|
||||
|
||||
Ok(BalanceResponse {
|
||||
id,
|
||||
subscription_title: usage.subscription_title().map(|s| s.to_string()),
|
||||
current_usage,
|
||||
usage_limit,
|
||||
remaining,
|
||||
usage_percentage,
|
||||
next_reset_at: usage.next_date_reset,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取所有凭据的缓存余额
|
||||
pub fn get_cached_balances(&self) -> CachedBalancesResponse {
|
||||
let balances = self
|
||||
.token_manager
|
||||
.get_all_cached_balances()
|
||||
.into_iter()
|
||||
.map(|info| CachedBalanceItem {
|
||||
id: info.id,
|
||||
remaining: info.remaining,
|
||||
cached_at: info.cached_at,
|
||||
ttl_secs: info.ttl_secs,
|
||||
})
|
||||
.collect();
|
||||
|
||||
CachedBalancesResponse { balances }
|
||||
}
|
||||
|
||||
/// 添加新凭据
|
||||
pub async fn add_credential(
|
||||
&self,
|
||||
req: AddCredentialRequest,
|
||||
) -> Result<AddCredentialResponse, AdminServiceError> {
|
||||
// 构建凭据对象
|
||||
let email = req.email.clone();
|
||||
let new_cred = KiroCredentials {
|
||||
id: None,
|
||||
access_token: None,
|
||||
refresh_token: Some(req.refresh_token),
|
||||
profile_arn: None,
|
||||
expires_at: None,
|
||||
auth_method: Some(req.auth_method),
|
||||
client_id: req.client_id,
|
||||
client_secret: req.client_secret,
|
||||
priority: req.priority,
|
||||
region: req.region,
|
||||
api_region: req.api_region,
|
||||
machine_id: req.machine_id,
|
||||
email: req.email,
|
||||
subscription_title: None,
|
||||
proxy_url: req.proxy_url,
|
||||
proxy_username: req.proxy_username,
|
||||
proxy_password: req.proxy_password,
|
||||
disabled: false, // 新添加的凭据默认启用
|
||||
};
|
||||
|
||||
// 调用 token_manager 添加凭据
|
||||
let credential_id = self
|
||||
.token_manager
|
||||
.add_credential(new_cred)
|
||||
.await
|
||||
.map_err(|e| self.classify_add_error(e))?;
|
||||
|
||||
Ok(AddCredentialResponse {
|
||||
success: true,
|
||||
message: format!("凭据添加成功,ID: {}", credential_id),
|
||||
credential_id,
|
||||
email,
|
||||
})
|
||||
}
|
||||
|
||||
/// 删除凭据
|
||||
pub fn delete_credential(&self, id: u64) -> Result<(), AdminServiceError> {
|
||||
self.token_manager
|
||||
.delete_credential(id)
|
||||
.map_err(|e| self.classify_delete_error(e, id))?;
|
||||
|
||||
// 清理已删除凭据的余额缓存
|
||||
{
|
||||
let mut cache = self.balance_cache.lock();
|
||||
cache.remove(&id);
|
||||
}
|
||||
self.save_balance_cache();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ 余额缓存持久化 ============
|
||||
|
||||
fn load_balance_cache_from(cache_path: &Option<PathBuf>) -> HashMap<u64, CachedBalance> {
|
||||
let path = match cache_path {
|
||||
Some(p) => p,
|
||||
None => return HashMap::new(),
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
|
||||
// 文件中使用字符串 key 以兼容 JSON 格式
|
||||
let map: HashMap<String, CachedBalance> = match serde_json::from_str(&content) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("解析余额缓存失败,将忽略: {}", e);
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now().timestamp() as f64;
|
||||
map.into_iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let id = k.parse::<u64>().ok()?;
|
||||
// 丢弃超过 TTL 的条目
|
||||
if (now - v.cached_at) < BALANCE_CACHE_TTL_SECS as f64 {
|
||||
Some((id, v))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_balance_cache(&self) {
|
||||
let path = match &self.cache_path {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 快速 clone 数据后释放锁,减少锁持有时间
|
||||
let map: HashMap<String, CachedBalance> = {
|
||||
let cache = self.balance_cache.lock();
|
||||
cache
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// 锁外执行序列化和文件 IO
|
||||
match serde_json::to_string_pretty(&map) {
|
||||
Ok(json) => {
|
||||
// 原子写入:先写临时文件,再重命名
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
match std::fs::write(&tmp_path, json) {
|
||||
Ok(_) => {
|
||||
if let Err(e) = std::fs::rename(&tmp_path, path) {
|
||||
tracing::warn!("原子重命名余额缓存失败: {}", e);
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("写入临时余额文件失败: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("序列化余额缓存失败: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 错误分类 ============
|
||||
|
||||
/// 分类简单操作错误(set_disabled, set_priority, reset_and_enable)
|
||||
fn classify_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("不存在") {
|
||||
AdminServiceError::NotFound { id }
|
||||
} else {
|
||||
AdminServiceError::InternalError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类余额查询错误(可能涉及上游 API 调用)
|
||||
fn classify_balance_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
|
||||
let msg = e.to_string();
|
||||
|
||||
// 1. 凭据不存在
|
||||
if msg.contains("不存在") {
|
||||
return AdminServiceError::NotFound { id };
|
||||
}
|
||||
|
||||
// 2. 上游服务错误特征:HTTP 响应错误或网络错误
|
||||
let is_upstream_error =
|
||||
// HTTP 响应错误(来自 refresh_*_token 的错误消息)
|
||||
msg.contains("凭证已过期或无效") ||
|
||||
msg.contains("权限不足") ||
|
||||
msg.contains("已被限流") ||
|
||||
msg.contains("服务器错误") ||
|
||||
msg.contains("Token 刷新失败") ||
|
||||
msg.contains("暂时不可用") ||
|
||||
// 网络错误(reqwest 错误)
|
||||
msg.contains("error trying to connect") ||
|
||||
msg.contains("connection") ||
|
||||
msg.contains("timeout") ||
|
||||
msg.contains("timed out");
|
||||
|
||||
if is_upstream_error {
|
||||
AdminServiceError::UpstreamError(msg)
|
||||
} else {
|
||||
// 3. 默认归类为内部错误(本地验证失败、配置错误等)
|
||||
// 包括:缺少 refreshToken、refreshToken 已被截断、无法生成 machineId 等
|
||||
AdminServiceError::InternalError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类添加凭据错误
|
||||
fn classify_add_error(&self, e: anyhow::Error) -> AdminServiceError {
|
||||
let msg = e.to_string();
|
||||
|
||||
// 凭据验证失败(refreshToken 无效、格式错误等)
|
||||
let is_invalid_credential = msg.contains("缺少 refreshToken")
|
||||
|| msg.contains("refreshToken 为空")
|
||||
|| msg.contains("refreshToken 已被截断")
|
||||
|| msg.contains("凭据已存在")
|
||||
|| msg.contains("refreshToken 重复")
|
||||
|| msg.contains("凭证已过期或无效")
|
||||
|| msg.contains("权限不足")
|
||||
|| msg.contains("已被限流");
|
||||
|
||||
if is_invalid_credential {
|
||||
AdminServiceError::InvalidCredential(msg)
|
||||
} else if msg.contains("error trying to connect")
|
||||
|| msg.contains("connection")
|
||||
|| msg.contains("timeout")
|
||||
{
|
||||
AdminServiceError::UpstreamError(msg)
|
||||
} else {
|
||||
AdminServiceError::InternalError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类删除凭据错误
|
||||
fn classify_delete_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("不存在") {
|
||||
AdminServiceError::NotFound { id }
|
||||
} else if msg.contains("只能删除已禁用的凭据") || msg.contains("请先禁用凭据")
|
||||
{
|
||||
AdminServiceError::InvalidCredential(msg)
|
||||
} else {
|
||||
AdminServiceError::InternalError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量导入 token.json
|
||||
///
|
||||
/// 解析官方 token.json 格式,按 provider 字段自动映射 authMethod:
|
||||
/// - BuilderId/builder-id/idc → idc
|
||||
/// - Social/social → social
|
||||
pub async fn import_token_json(&self, req: ImportTokenJsonRequest) -> ImportTokenJsonResponse {
|
||||
let items = req.items.into_vec();
|
||||
let dry_run = req.dry_run;
|
||||
|
||||
let mut results = Vec::with_capacity(items.len());
|
||||
let mut added = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
let mut invalid = 0usize;
|
||||
|
||||
for (index, item) in items.into_iter().enumerate() {
|
||||
let result = self.process_token_json_item(index, item, dry_run).await;
|
||||
match result.action {
|
||||
ImportAction::Added => added += 1,
|
||||
ImportAction::Skipped => skipped += 1,
|
||||
ImportAction::Invalid => invalid += 1,
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
ImportTokenJsonResponse {
|
||||
summary: ImportSummary {
|
||||
parsed: results.len(),
|
||||
added,
|
||||
skipped,
|
||||
invalid,
|
||||
},
|
||||
items: results,
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理单个 token.json 项
|
||||
async fn process_token_json_item(
|
||||
&self,
|
||||
index: usize,
|
||||
item: TokenJsonItem,
|
||||
dry_run: bool,
|
||||
) -> ImportItemResult {
|
||||
// 生成指纹(用于识别和去重)
|
||||
let fingerprint = Self::generate_fingerprint(&item);
|
||||
|
||||
// 验证必填字段
|
||||
let refresh_token = match &item.refresh_token {
|
||||
Some(rt) if !rt.is_empty() => rt.clone(),
|
||||
_ => {
|
||||
return ImportItemResult {
|
||||
index,
|
||||
fingerprint,
|
||||
action: ImportAction::Invalid,
|
||||
reason: Some("缺少 refreshToken".to_string()),
|
||||
credential_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 映射 authMethod
|
||||
let auth_method = Self::map_auth_method(&item);
|
||||
|
||||
// IdC 需要 clientId 和 clientSecret
|
||||
if auth_method == "idc" && (item.client_id.is_none() || item.client_secret.is_none()) {
|
||||
return ImportItemResult {
|
||||
index,
|
||||
fingerprint,
|
||||
action: ImportAction::Invalid,
|
||||
reason: Some(format!("{} 认证需要 clientId 和 clientSecret", auth_method)),
|
||||
credential_id: None,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否已存在(通过 refreshToken 前缀匹配)
|
||||
if self.token_manager.has_refresh_token_prefix(&refresh_token) {
|
||||
return ImportItemResult {
|
||||
index,
|
||||
fingerprint,
|
||||
action: ImportAction::Skipped,
|
||||
reason: Some("凭据已存在".to_string()),
|
||||
credential_id: None,
|
||||
};
|
||||
}
|
||||
|
||||
// dry-run 模式只返回预览
|
||||
if dry_run {
|
||||
return ImportItemResult {
|
||||
index,
|
||||
fingerprint,
|
||||
action: ImportAction::Added,
|
||||
reason: Some("预览模式".to_string()),
|
||||
credential_id: None,
|
||||
};
|
||||
}
|
||||
|
||||
// 实际添加凭据(trim + 空字符串转 None,与 set_region 逻辑一致)
|
||||
let region = item
|
||||
.region
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let api_region = item
|
||||
.api_region
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let new_cred = KiroCredentials {
|
||||
id: None,
|
||||
access_token: None,
|
||||
refresh_token: Some(refresh_token),
|
||||
profile_arn: None,
|
||||
expires_at: None,
|
||||
auth_method: Some(auth_method),
|
||||
client_id: item.client_id,
|
||||
client_secret: item.client_secret,
|
||||
priority: item.priority,
|
||||
region,
|
||||
api_region,
|
||||
machine_id: item.machine_id,
|
||||
email: None,
|
||||
subscription_title: None,
|
||||
proxy_url: None,
|
||||
proxy_username: None,
|
||||
proxy_password: None,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
match self.token_manager.add_credential(new_cred).await {
|
||||
Ok(credential_id) => ImportItemResult {
|
||||
index,
|
||||
fingerprint,
|
||||
action: ImportAction::Added,
|
||||
reason: None,
|
||||
credential_id: Some(credential_id),
|
||||
},
|
||||
Err(e) => ImportItemResult {
|
||||
index,
|
||||
fingerprint,
|
||||
action: ImportAction::Invalid,
|
||||
reason: Some(e.to_string()),
|
||||
credential_id: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成凭据指纹(用于识别)
|
||||
fn generate_fingerprint(item: &TokenJsonItem) -> String {
|
||||
// 使用 refreshToken 前 16 字符作为指纹
|
||||
// 使用 floor_char_boundary 安全截断,避免在多字节字符中间切割导致 panic
|
||||
item.refresh_token
|
||||
.as_ref()
|
||||
.map(|rt| {
|
||||
if rt.len() >= 16 {
|
||||
let end = floor_char_boundary(rt, 16);
|
||||
format!("{}...", &rt[..end])
|
||||
} else {
|
||||
rt.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "(empty)".to_string())
|
||||
}
|
||||
|
||||
/// 映射 provider/authMethod 到标准 authMethod
|
||||
fn map_auth_method(item: &TokenJsonItem) -> String {
|
||||
// 优先使用 authMethod 字段
|
||||
if let Some(auth) = &item.auth_method {
|
||||
let auth_lower = auth.to_lowercase();
|
||||
return match auth_lower.as_str() {
|
||||
"idc" | "builder-id" | "builderid" => "idc".to_string(),
|
||||
"social" => "social".to_string(),
|
||||
_ => auth_lower,
|
||||
};
|
||||
}
|
||||
|
||||
// 回退到 provider 字段
|
||||
if let Some(provider) = &item.provider {
|
||||
let provider_lower = provider.to_lowercase();
|
||||
return match provider_lower.as_str() {
|
||||
"builderid" | "builder-id" | "idc" => "idc".to_string(),
|
||||
"social" => "social".to_string(),
|
||||
_ => "social".to_string(), // 默认 social
|
||||
};
|
||||
}
|
||||
|
||||
// 默认 social
|
||||
"social".to_string()
|
||||
}
|
||||
}
|
||||
335
src/admin/types.rs
Normal file
335
src/admin/types.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
//! Admin API 类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============ 凭据状态 ============
|
||||
|
||||
/// 所有凭据状态响应
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialsStatusResponse {
|
||||
/// 凭据总数
|
||||
pub total: usize,
|
||||
/// 可用凭据数量(未禁用)
|
||||
pub available: usize,
|
||||
/// 各凭据状态列表
|
||||
pub credentials: Vec<CredentialStatusItem>,
|
||||
}
|
||||
|
||||
/// 单个凭据的状态信息
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CredentialStatusItem {
|
||||
/// 凭据唯一 ID
|
||||
pub id: u64,
|
||||
/// 优先级(数字越小优先级越高)
|
||||
pub priority: u32,
|
||||
/// 是否被禁用
|
||||
pub disabled: bool,
|
||||
/// 连续失败次数
|
||||
pub failure_count: u32,
|
||||
/// Token 过期时间(RFC3339 格式)
|
||||
pub expires_at: Option<String>,
|
||||
/// 认证方式
|
||||
pub auth_method: Option<String>,
|
||||
/// 是否有 Profile ARN
|
||||
pub has_profile_arn: bool,
|
||||
/// refreshToken 的 SHA-256 哈希(用于前端重复检测)
|
||||
pub refresh_token_hash: Option<String>,
|
||||
/// 用户邮箱(用于前端显示)
|
||||
pub email: Option<String>,
|
||||
/// API 调用成功次数
|
||||
pub success_count: u64,
|
||||
/// 最后一次 API 调用时间(RFC3339 格式)
|
||||
pub last_used_at: Option<String>,
|
||||
/// 凭据级 Region(用于 Token 刷新)
|
||||
pub region: Option<String>,
|
||||
/// 凭据级 API Region(单独覆盖 API 请求)
|
||||
pub api_region: Option<String>,
|
||||
}
|
||||
|
||||
// ============ 操作请求 ============
|
||||
|
||||
/// 启用/禁用凭据请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDisabledRequest {
|
||||
/// 是否禁用
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
/// 修改优先级请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetPriorityRequest {
|
||||
/// 新优先级值
|
||||
pub priority: u32,
|
||||
}
|
||||
|
||||
/// 修改 Region 请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetRegionRequest {
|
||||
/// 凭据级 Region(用于 Token 刷新),空字符串表示清除
|
||||
pub region: Option<String>,
|
||||
/// 凭据级 API Region(单独覆盖 API 请求),空字符串表示清除
|
||||
pub api_region: Option<String>,
|
||||
}
|
||||
|
||||
/// 添加凭据请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AddCredentialRequest {
|
||||
/// 刷新令牌(必填)
|
||||
pub refresh_token: String,
|
||||
|
||||
/// 认证方式(可选,默认 social)
|
||||
#[serde(default = "default_auth_method")]
|
||||
pub auth_method: String,
|
||||
|
||||
/// OIDC Client ID(IdC 认证需要)
|
||||
pub client_id: Option<String>,
|
||||
|
||||
/// OIDC Client Secret(IdC 认证需要)
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
/// 优先级(可选,默认 0)
|
||||
#[serde(default)]
|
||||
pub priority: u32,
|
||||
|
||||
/// 凭据级 Region 配置(用于 Token 刷新)
|
||||
/// 未配置时回退到 config.json 的全局 region
|
||||
pub region: Option<String>,
|
||||
|
||||
/// 凭据级 API Region(用于 API 调用)
|
||||
pub api_region: Option<String>,
|
||||
|
||||
/// 凭据级 Machine ID(可选,64 位字符串)
|
||||
/// 未配置时回退到 config.json 的 machineId
|
||||
pub machine_id: Option<String>,
|
||||
|
||||
/// 用户邮箱(可选,用于前端显示)
|
||||
pub email: Option<String>,
|
||||
|
||||
/// 凭据级代理 URL
|
||||
pub proxy_url: Option<String>,
|
||||
|
||||
/// 凭据级代理用户名
|
||||
pub proxy_username: Option<String>,
|
||||
|
||||
/// 凭据级代理密码
|
||||
pub proxy_password: Option<String>,
|
||||
}
|
||||
|
||||
fn default_auth_method() -> String {
|
||||
"social".to_string()
|
||||
}
|
||||
|
||||
/// 添加凭据成功响应
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AddCredentialResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
/// 新添加的凭据 ID
|
||||
pub credential_id: u64,
|
||||
/// 用户邮箱(如果获取成功)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
// ============ 余额查询 ============
|
||||
|
||||
/// 余额查询响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BalanceResponse {
|
||||
/// 凭据 ID
|
||||
pub id: u64,
|
||||
/// 订阅类型
|
||||
pub subscription_title: Option<String>,
|
||||
/// 当前使用量
|
||||
pub current_usage: f64,
|
||||
/// 使用限额
|
||||
pub usage_limit: f64,
|
||||
/// 剩余额度
|
||||
pub remaining: f64,
|
||||
/// 使用百分比
|
||||
pub usage_percentage: f64,
|
||||
/// 下次重置时间(Unix 时间戳)
|
||||
pub next_reset_at: Option<f64>,
|
||||
}
|
||||
|
||||
/// 缓存余额信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CachedBalanceItem {
|
||||
/// 凭据 ID
|
||||
pub id: u64,
|
||||
/// 缓存的剩余额度
|
||||
pub remaining: f64,
|
||||
/// 缓存时间(Unix 毫秒时间戳)
|
||||
pub cached_at: u64,
|
||||
/// 缓存存活时间(秒),缓存过期时间 = cached_at + ttl_secs * 1000
|
||||
pub ttl_secs: u64,
|
||||
}
|
||||
|
||||
/// 所有凭据的缓存余额响应
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CachedBalancesResponse {
|
||||
/// 各凭据的缓存余额列表
|
||||
pub balances: Vec<CachedBalanceItem>,
|
||||
}
|
||||
|
||||
// ============ 负载均衡配置 ============
|
||||
|
||||
// ============ 通用响应 ============
|
||||
|
||||
/// 操作成功响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SuccessResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl SuccessResponse {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 批量导入 token.json ============
|
||||
|
||||
/// 官方 token.json 格式(用于解析导入)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenJsonItem {
|
||||
pub provider: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub client_secret: Option<String>,
|
||||
pub auth_method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub priority: u32,
|
||||
pub region: Option<String>,
|
||||
pub api_region: Option<String>,
|
||||
pub machine_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 批量导入请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportTokenJsonRequest {
|
||||
#[serde(default = "default_dry_run")]
|
||||
pub dry_run: bool,
|
||||
pub items: ImportItems,
|
||||
}
|
||||
|
||||
fn default_dry_run() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 导入项(支持单个或数组)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ImportItems {
|
||||
Single(TokenJsonItem),
|
||||
Multiple(Vec<TokenJsonItem>),
|
||||
}
|
||||
|
||||
impl ImportItems {
|
||||
pub fn into_vec(self) -> Vec<TokenJsonItem> {
|
||||
match self {
|
||||
ImportItems::Single(item) => vec![item],
|
||||
ImportItems::Multiple(items) => items,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量导入响应
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportTokenJsonResponse {
|
||||
pub summary: ImportSummary,
|
||||
pub items: Vec<ImportItemResult>,
|
||||
}
|
||||
|
||||
/// 导入汇总
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportSummary {
|
||||
pub parsed: usize,
|
||||
pub added: usize,
|
||||
pub skipped: usize,
|
||||
pub invalid: usize,
|
||||
}
|
||||
|
||||
/// 单项导入结果
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportItemResult {
|
||||
pub index: usize,
|
||||
pub fingerprint: String,
|
||||
pub action: ImportAction,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// 导入动作
|
||||
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ImportAction {
|
||||
Added,
|
||||
Skipped,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// 错误响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AdminErrorResponse {
|
||||
pub error: AdminError,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AdminError {
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl AdminErrorResponse {
|
||||
pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error: AdminError {
|
||||
error_type: error_type.into(),
|
||||
message: message.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self::new("invalid_request", message)
|
||||
}
|
||||
|
||||
pub fn authentication_error() -> Self {
|
||||
Self::new("authentication_error", "Invalid or missing admin API key")
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new("not_found", message)
|
||||
}
|
||||
|
||||
pub fn api_error(message: impl Into<String>) -> Self {
|
||||
Self::new("api_error", message)
|
||||
}
|
||||
|
||||
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||
Self::new("internal_error", message)
|
||||
}
|
||||
}
|
||||
7
src/admin_ui/mod.rs
Normal file
7
src/admin_ui/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Admin UI 静态文件服务模块
|
||||
//!
|
||||
//! 使用 rust-embed 嵌入前端构建产物
|
||||
|
||||
mod router;
|
||||
|
||||
pub use router::create_admin_ui_router;
|
||||
109
src/admin_ui/router.rs
Normal file
109
src/admin_ui/router.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! Admin UI 路由配置
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Response, StatusCode, Uri, header},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use rust_embed::Embed;
|
||||
|
||||
/// 嵌入前端构建产物
|
||||
#[derive(Embed)]
|
||||
#[folder = "admin-ui/dist"]
|
||||
struct Asset;
|
||||
|
||||
/// 创建 Admin UI 路由
|
||||
pub fn create_admin_ui_router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/{*file}", get(static_handler))
|
||||
}
|
||||
|
||||
/// 处理首页请求
|
||||
async fn index_handler() -> impl IntoResponse {
|
||||
serve_index()
|
||||
}
|
||||
|
||||
/// 处理静态文件请求
|
||||
async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
|
||||
// 安全检查:拒绝包含 .. 的路径
|
||||
if path.contains("..") {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from("Invalid path"))
|
||||
.expect("Failed to build response");
|
||||
}
|
||||
|
||||
// 尝试获取请求的文件
|
||||
if let Some(content) = Asset::get(path) {
|
||||
let mime = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
|
||||
// 根据文件类型设置不同的缓存策略
|
||||
let cache_control = get_cache_control(path);
|
||||
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.header(header::CACHE_CONTROL, cache_control)
|
||||
.body(Body::from(content.data.into_owned()))
|
||||
.expect("Failed to build response");
|
||||
}
|
||||
|
||||
// SPA fallback: 如果文件不存在且不是资源文件,返回 index.html
|
||||
if !is_asset_path(path) {
|
||||
return serve_index();
|
||||
}
|
||||
|
||||
// 404
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("Not found"))
|
||||
.expect("Failed to build response")
|
||||
}
|
||||
|
||||
/// 提供 index.html
|
||||
fn serve_index() -> Response<Body> {
|
||||
match Asset::get("index.html") {
|
||||
Some(content) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.body(Body::from(content.data.into_owned()))
|
||||
.expect("Failed to build response"),
|
||||
None => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from(
|
||||
"Admin UI not built. Run 'pnpm build' in admin-ui directory.",
|
||||
))
|
||||
.expect("Failed to build response"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件类型返回合适的缓存策略
|
||||
fn get_cache_control(path: &str) -> &'static str {
|
||||
if path.ends_with(".html") {
|
||||
// HTML 文件不缓存,确保用户获取最新版本
|
||||
"no-cache"
|
||||
} else if path.starts_with("assets/") {
|
||||
// assets/ 目录下的文件带有内容哈希,可以长期缓存
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
// 其他文件(如 favicon)使用较短的缓存
|
||||
"public, max-age=3600"
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为资源文件路径(有扩展名的文件)
|
||||
fn is_asset_path(path: &str) -> bool {
|
||||
// 检查最后一个路径段是否包含扩展名
|
||||
path.rsplit('/')
|
||||
.next()
|
||||
.map(|filename| filename.contains('.'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
1063
src/anthropic/compressor.rs
Normal file
1063
src/anthropic/compressor.rs
Normal file
File diff suppressed because it is too large
Load Diff
2852
src/anthropic/converter.rs
Normal file
2852
src/anthropic/converter.rs
Normal file
File diff suppressed because it is too large
Load Diff
1731
src/anthropic/handlers.rs
Normal file
1731
src/anthropic/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
94
src/anthropic/middleware.rs
Normal file
94
src/anthropic/middleware.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
//! Anthropic API 中间件
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
|
||||
use crate::common::auth;
|
||||
use crate::kiro::provider::KiroProvider;
|
||||
use crate::model::config::CompressionConfig;
|
||||
|
||||
use super::types::ErrorResponse;
|
||||
|
||||
/// 应用共享状态
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// API 密钥
|
||||
pub api_key: String,
|
||||
/// Kiro Provider(可选,用于实际 API 调用)
|
||||
/// 内部使用 MultiTokenManager,已支持线程安全的多凭据管理
|
||||
pub kiro_provider: Option<Arc<KiroProvider>>,
|
||||
/// Profile ARN(可选,用于请求)
|
||||
pub profile_arn: Option<String>,
|
||||
/// 输入压缩配置
|
||||
pub compression_config: CompressionConfig,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub fn new(api_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
api_key: api_key.into(),
|
||||
kiro_provider: None,
|
||||
profile_arn: None,
|
||||
compression_config: CompressionConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 KiroProvider
|
||||
pub fn with_kiro_provider(mut self, provider: KiroProvider) -> Self {
|
||||
self.kiro_provider = Some(Arc::new(provider));
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置 Profile ARN
|
||||
pub fn with_profile_arn(mut self, arn: impl Into<String>) -> Self {
|
||||
self.profile_arn = Some(arn.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置压缩配置
|
||||
pub fn with_compression_config(mut self, config: CompressionConfig) -> Self {
|
||||
self.compression_config = config;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// API Key 认证中间件
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
match auth::extract_api_key(&request) {
|
||||
Some(key) if auth::constant_time_eq(&key, &state.api_key) => next.run(request).await,
|
||||
_ => {
|
||||
let error = ErrorResponse::authentication_error();
|
||||
(StatusCode::UNAUTHORIZED, Json(error)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CORS 中间件层
|
||||
///
|
||||
/// **安全说明**:当前配置允许所有来源(Any),这是为了支持公开 API 服务。
|
||||
/// 如果需要更严格的安全控制,请根据实际需求配置具体的允许来源、方法和头信息。
|
||||
///
|
||||
/// # 配置说明
|
||||
/// - `allow_origin(Any)`: 允许任何来源的请求
|
||||
/// - `allow_methods(Any)`: 允许任何 HTTP 方法
|
||||
/// - `allow_headers(Any)`: 允许任何请求头
|
||||
pub fn cors_layer() -> tower_http::cors::CorsLayer {
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
}
|
||||
36
src/anthropic/mod.rs
Normal file
36
src/anthropic/mod.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! Anthropic API 兼容服务模块
|
||||
//!
|
||||
//! 提供与 Anthropic Claude API 兼容的 HTTP 服务端点。
|
||||
//!
|
||||
//! # 支持的端点
|
||||
//!
|
||||
//! ## 标准端点 (/v1)
|
||||
//! - `GET /v1/models` - 获取可用模型列表
|
||||
//! - `POST /v1/messages` - 创建消息(对话)
|
||||
//! - `POST /v1/messages/count_tokens` - 计算 token 数量
|
||||
//!
|
||||
//! ## Claude Code 兼容端点 (/cc/v1)
|
||||
//! - `POST /cc/v1/messages` - 创建消息(流式响应会等待 contextUsageEvent 后再发送 message_start,确保 input_tokens 准确)
|
||||
//! - `POST /cc/v1/messages/count_tokens` - 计算 token 数量(与 /v1 相同)
|
||||
//!
|
||||
//! # 使用示例
|
||||
//! ```rust,ignore
|
||||
//! use kiro_rs::anthropic;
|
||||
//!
|
||||
//! let app = anthropic::create_router("your-api-key");
|
||||
//! let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||
//! axum::serve(listener, app).await?;
|
||||
//! ```
|
||||
|
||||
mod compressor;
|
||||
mod converter;
|
||||
mod handlers;
|
||||
mod middleware;
|
||||
mod router;
|
||||
mod stream;
|
||||
mod tool_compression;
|
||||
mod truncation;
|
||||
pub mod types;
|
||||
mod websearch;
|
||||
|
||||
pub use router::create_router_with_provider;
|
||||
79
src/anthropic/router.rs
Normal file
79
src/anthropic/router.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Anthropic API 路由配置
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::kiro::provider::KiroProvider;
|
||||
use crate::model::config::CompressionConfig;
|
||||
|
||||
use super::{
|
||||
handlers::{count_tokens, get_models, post_messages, post_messages_cc},
|
||||
middleware::{AppState, auth_middleware, cors_layer},
|
||||
};
|
||||
|
||||
/// 请求体最大大小限制 (50MB)
|
||||
const MAX_BODY_SIZE: usize = 50 * 1024 * 1024;
|
||||
|
||||
/// 创建 Anthropic API 路由
|
||||
///
|
||||
/// # 端点
|
||||
/// - `GET /v1/models` - 获取可用模型列表
|
||||
/// - `POST /v1/messages` - 创建消息(对话)
|
||||
/// - `POST /v1/messages/count_tokens` - 计算 token 数量
|
||||
///
|
||||
/// # 认证
|
||||
/// 所有 `/v1` 路径需要 API Key 认证,支持:
|
||||
/// - `x-api-key` header
|
||||
/// - `Authorization: Bearer <token>` header
|
||||
///
|
||||
/// # 参数
|
||||
/// - `api_key`: API 密钥,用于验证客户端请求
|
||||
/// - `kiro_provider`: 可选的 KiroProvider,用于调用上游 API
|
||||
///
|
||||
/// 创建带有 KiroProvider 的 Anthropic API 路由
|
||||
pub fn create_router_with_provider(
|
||||
api_key: impl Into<String>,
|
||||
kiro_provider: Option<KiroProvider>,
|
||||
profile_arn: Option<String>,
|
||||
compression_config: CompressionConfig,
|
||||
) -> Router {
|
||||
let mut state = AppState::new(api_key);
|
||||
if let Some(provider) = kiro_provider {
|
||||
state = state.with_kiro_provider(provider);
|
||||
}
|
||||
if let Some(arn) = profile_arn {
|
||||
state = state.with_profile_arn(arn);
|
||||
}
|
||||
state = state.with_compression_config(compression_config);
|
||||
|
||||
// 需要认证的 /v1 路由
|
||||
let v1_routes = Router::new()
|
||||
.route("/models", get(get_models))
|
||||
.route("/messages", post(post_messages))
|
||||
.route("/messages/count_tokens", post(count_tokens))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
// 需要认证的 /cc/v1 路由(Claude Code 兼容端点)
|
||||
// 与 /v1 的区别:流式响应会等待 contextUsageEvent 后再发送 message_start
|
||||
let cc_v1_routes = Router::new()
|
||||
.route("/messages", post(post_messages_cc))
|
||||
.route("/messages/count_tokens", post(count_tokens))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.nest("/v1", v1_routes)
|
||||
.nest("/cc/v1", cc_v1_routes)
|
||||
.layer(cors_layer())
|
||||
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
|
||||
.with_state(state)
|
||||
}
|
||||
1937
src/anthropic/stream.rs
Normal file
1937
src/anthropic/stream.rs
Normal file
File diff suppressed because it is too large
Load Diff
276
src/anthropic/tool_compression.rs
Normal file
276
src/anthropic/tool_compression.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! 工具定义压缩模块
|
||||
//!
|
||||
//! 当工具定义的总序列化大小超过阈值时,通过两步压缩减小体积:
|
||||
//! 1. 简化 `input_schema`:移除非必要字段(description 等),仅保留结构骨架
|
||||
//! 2. 按比例截断 `description`:根据超出比例缩短描述,最短保留 50 字符
|
||||
|
||||
use crate::kiro::model::requests::tool::{InputSchema, Tool as KiroTool, ToolSpecification};
|
||||
|
||||
/// 工具定义总大小阈值(20KB)
|
||||
const TOOL_SIZE_THRESHOLD: usize = 20 * 1024;
|
||||
|
||||
/// description 最短保留字符数
|
||||
const MIN_DESCRIPTION_CHARS: usize = 50;
|
||||
|
||||
/// 如果工具定义总大小超过阈值,执行压缩
|
||||
///
|
||||
/// 返回压缩后的工具列表(如果未超阈值则原样返回)
|
||||
pub fn compress_tools_if_needed(tools: &[KiroTool]) -> Vec<KiroTool> {
|
||||
let total_size = estimate_tools_size(tools);
|
||||
if total_size <= TOOL_SIZE_THRESHOLD {
|
||||
return tools.to_vec();
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
total_size,
|
||||
threshold = TOOL_SIZE_THRESHOLD,
|
||||
tool_count = tools.len(),
|
||||
"工具定义超过阈值,开始压缩"
|
||||
);
|
||||
|
||||
// 第一步:简化 input_schema
|
||||
let mut compressed: Vec<KiroTool> = tools.iter().map(simplify_schema).collect();
|
||||
|
||||
let size_after_schema = estimate_tools_size(&compressed);
|
||||
if size_after_schema <= TOOL_SIZE_THRESHOLD {
|
||||
tracing::info!(
|
||||
original_size = total_size,
|
||||
compressed_size = size_after_schema,
|
||||
"schema 简化后已低于阈值"
|
||||
);
|
||||
return compressed;
|
||||
}
|
||||
// 第二步:按比例截断 description(基于字节大小)
|
||||
let ratio = TOOL_SIZE_THRESHOLD as f64 / size_after_schema as f64;
|
||||
for tool in &mut compressed {
|
||||
let desc = &tool.tool_specification.description;
|
||||
let target_bytes = (desc.len() as f64 * ratio) as usize;
|
||||
// 最短保留 MIN_DESCRIPTION_CHARS 个字符对应的字节数(至少 50 字符)
|
||||
let min_bytes = desc
|
||||
.char_indices()
|
||||
.nth(MIN_DESCRIPTION_CHARS)
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(desc.len());
|
||||
let target_bytes = target_bytes.max(min_bytes);
|
||||
if desc.len() > target_bytes {
|
||||
// UTF-8 安全截断:找到不超过 target_bytes 的最大字符边界
|
||||
let truncate_at = desc
|
||||
.char_indices()
|
||||
.take_while(|(idx, _)| *idx <= target_bytes)
|
||||
.last()
|
||||
.map(|(idx, ch)| idx + ch.len_utf8())
|
||||
.unwrap_or(0);
|
||||
tool.tool_specification.description = desc[..truncate_at].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let final_size = estimate_tools_size(&compressed);
|
||||
tracing::info!(
|
||||
original_size = total_size,
|
||||
after_schema = size_after_schema,
|
||||
final_size,
|
||||
"工具压缩完成"
|
||||
);
|
||||
|
||||
compressed
|
||||
}
|
||||
|
||||
/// 估算工具列表的总序列化大小(字节)
|
||||
fn estimate_tools_size(tools: &[KiroTool]) -> usize {
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let spec = &t.tool_specification;
|
||||
spec.name.len()
|
||||
+ spec.description.len()
|
||||
+ serde_json::to_string(&spec.input_schema.json)
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// 简化工具的 input_schema
|
||||
///
|
||||
/// 保留结构骨架(type, properties 的 key 和 type, required),
|
||||
/// 移除 properties 内部的 description、examples 等非必要字段
|
||||
fn simplify_schema(tool: &KiroTool) -> KiroTool {
|
||||
let schema = &tool.tool_specification.input_schema.json;
|
||||
let simplified = simplify_json_schema(schema);
|
||||
|
||||
KiroTool {
|
||||
tool_specification: ToolSpecification {
|
||||
name: tool.tool_specification.name.clone(),
|
||||
description: tool.tool_specification.description.clone(),
|
||||
input_schema: InputSchema::from_json(simplified),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归简化 JSON Schema
|
||||
fn simplify_json_schema(schema: &serde_json::Value) -> serde_json::Value {
|
||||
let Some(obj) = schema.as_object() else {
|
||||
return schema.clone();
|
||||
};
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
// 保留顶层结构字段
|
||||
for key in &["$schema", "type", "required", "additionalProperties"] {
|
||||
if let Some(v) = obj.get(*key) {
|
||||
result.insert(key.to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 简化 properties:仅保留每个属性的 type
|
||||
if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
|
||||
let mut simplified_props = serde_json::Map::new();
|
||||
for (name, prop_schema) in props {
|
||||
if let Some(prop_obj) = prop_schema.as_object() {
|
||||
let mut simplified_prop = serde_json::Map::new();
|
||||
// 保留 type
|
||||
if let Some(ty) = prop_obj.get("type") {
|
||||
simplified_prop.insert("type".to_string(), ty.clone());
|
||||
}
|
||||
// 递归简化嵌套 properties(如 object 类型)
|
||||
if let Some(nested_props) = prop_obj.get("properties") {
|
||||
// 构造完整的子 schema,保留 required 和 additionalProperties
|
||||
let mut nested_schema = serde_json::Map::new();
|
||||
nested_schema.insert(
|
||||
"type".to_string(),
|
||||
serde_json::Value::String("object".to_string()),
|
||||
);
|
||||
nested_schema.insert("properties".to_string(), nested_props.clone());
|
||||
if let Some(req) = prop_obj.get("required") {
|
||||
nested_schema.insert("required".to_string(), req.clone());
|
||||
}
|
||||
if let Some(ap) = prop_obj.get("additionalProperties") {
|
||||
nested_schema.insert("additionalProperties".to_string(), ap.clone());
|
||||
}
|
||||
let nested = simplify_json_schema(&serde_json::Value::Object(nested_schema));
|
||||
if let Some(np) = nested.get("properties") {
|
||||
simplified_prop.insert("properties".to_string(), np.clone());
|
||||
}
|
||||
if let Some(req) = nested.get("required") {
|
||||
simplified_prop.insert("required".to_string(), req.clone());
|
||||
}
|
||||
if let Some(ap) = nested.get("additionalProperties") {
|
||||
simplified_prop.insert("additionalProperties".to_string(), ap.clone());
|
||||
}
|
||||
}
|
||||
// 保留 items(数组类型)
|
||||
if let Some(items) = prop_obj.get("items") {
|
||||
simplified_prop.insert("items".to_string(), simplify_json_schema(items));
|
||||
}
|
||||
// 保留 enum
|
||||
if let Some(e) = prop_obj.get("enum") {
|
||||
simplified_prop.insert("enum".to_string(), e.clone());
|
||||
}
|
||||
simplified_props.insert(name.clone(), serde_json::Value::Object(simplified_prop));
|
||||
} else {
|
||||
simplified_props.insert(name.clone(), prop_schema.clone());
|
||||
}
|
||||
}
|
||||
result.insert(
|
||||
"properties".to_string(),
|
||||
serde_json::Value::Object(simplified_props),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::Value::Object(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_tool(name: &str, desc: &str, schema: serde_json::Value) -> KiroTool {
|
||||
KiroTool {
|
||||
tool_specification: ToolSpecification {
|
||||
name: name.to_string(),
|
||||
description: desc.to_string(),
|
||||
input_schema: InputSchema::from_json(schema),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_compression_under_threshold() {
|
||||
let tools = vec![make_tool(
|
||||
"test",
|
||||
"A short description",
|
||||
serde_json::json!({"type": "object", "properties": {}}),
|
||||
)];
|
||||
let result = compress_tools_if_needed(&tools);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(
|
||||
result[0].tool_specification.description,
|
||||
"A short description"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compression_triggers_over_threshold() {
|
||||
// 创建大量工具使总大小超过 20KB
|
||||
let long_desc = "x".repeat(2000);
|
||||
let tools: Vec<KiroTool> = (0..15)
|
||||
.map(|i| {
|
||||
make_tool(
|
||||
&format!("tool_{}", i),
|
||||
&long_desc,
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param1": {"type": "string", "description": "A very long parameter description that adds to the size"},
|
||||
"param2": {"type": "number", "description": "Another long description for testing purposes"}
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let original_size = estimate_tools_size(&tools);
|
||||
assert!(original_size > TOOL_SIZE_THRESHOLD, "测试数据应超过阈值");
|
||||
|
||||
let result = compress_tools_if_needed(&tools);
|
||||
let compressed_size = estimate_tools_size(&result);
|
||||
assert!(
|
||||
compressed_size < original_size,
|
||||
"压缩后应更小: {} < {}",
|
||||
compressed_size,
|
||||
original_size
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simplify_schema_removes_descriptions() {
|
||||
let tool = make_tool(
|
||||
"test",
|
||||
"desc",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The file path to read"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
);
|
||||
|
||||
let simplified = simplify_schema(&tool);
|
||||
let props = simplified
|
||||
.tool_specification
|
||||
.input_schema
|
||||
.json
|
||||
.get("properties")
|
||||
.unwrap();
|
||||
let path_prop = props.get("path").unwrap();
|
||||
|
||||
// description 应被移除
|
||||
assert!(path_prop.get("description").is_none());
|
||||
// type 应保留
|
||||
assert_eq!(path_prop.get("type").unwrap(), "string");
|
||||
}
|
||||
}
|
||||
282
src/anthropic/truncation.rs
Normal file
282
src/anthropic/truncation.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
//! 工具调用截断检测模块
|
||||
//!
|
||||
//! 当上游返回的工具调用 JSON 被截断时(例如因为 max_tokens 限制),
|
||||
//! 提供启发式检测和软失败恢复机制,引导模型分块重试。
|
||||
|
||||
/// 截断类型
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TruncationType {
|
||||
/// 工具输入为空(上游可能完全截断了 input)
|
||||
EmptyInput,
|
||||
/// JSON 解析失败(不完整的 JSON)
|
||||
InvalidJson,
|
||||
/// 缺少必要字段(JSON 有效但结构不完整)
|
||||
#[allow(dead_code)]
|
||||
MissingFields,
|
||||
/// 未闭合的字符串(JSON 字符串被截断)
|
||||
IncompleteString,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TruncationType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TruncationType::EmptyInput => write!(f, "empty_input"),
|
||||
TruncationType::InvalidJson => write!(f, "invalid_json"),
|
||||
TruncationType::MissingFields => write!(f, "missing_fields"),
|
||||
TruncationType::IncompleteString => write!(f, "incomplete_string"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 截断检测结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TruncationInfo {
|
||||
/// 截断类型
|
||||
pub truncation_type: TruncationType,
|
||||
/// 工具名称
|
||||
pub tool_name: String,
|
||||
/// 工具调用 ID
|
||||
pub tool_use_id: String,
|
||||
/// 原始输入(可能不完整)
|
||||
#[allow(dead_code)]
|
||||
pub raw_input: String,
|
||||
}
|
||||
|
||||
/// 检测工具调用输入是否被截断
|
||||
///
|
||||
/// 启发式判断规则:
|
||||
/// 1. 空输入 → EmptyInput
|
||||
/// 2. 未闭合的引号 → IncompleteString
|
||||
/// 3. 括号不平衡 → InvalidJson
|
||||
/// 4. 末尾字符异常(非 `}` / `]` / `"` / 数字 / `true` / `false` / `null`)→ InvalidJson
|
||||
pub fn detect_truncation(
|
||||
tool_name: &str,
|
||||
tool_use_id: &str,
|
||||
raw_input: &str,
|
||||
) -> Option<TruncationInfo> {
|
||||
let trimmed = raw_input.trim();
|
||||
|
||||
// 空输入
|
||||
if trimmed.is_empty() {
|
||||
return Some(TruncationInfo {
|
||||
truncation_type: TruncationType::EmptyInput,
|
||||
tool_name: tool_name.to_string(),
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
raw_input: raw_input.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 检查未闭合的字符串引号
|
||||
if has_unclosed_string(trimmed) {
|
||||
return Some(TruncationInfo {
|
||||
truncation_type: TruncationType::IncompleteString,
|
||||
tool_name: tool_name.to_string(),
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
raw_input: raw_input.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 检查括号平衡
|
||||
if !are_brackets_balanced(trimmed) {
|
||||
return Some(TruncationInfo {
|
||||
truncation_type: TruncationType::InvalidJson,
|
||||
tool_name: tool_name.to_string(),
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
raw_input: raw_input.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 构建软失败的工具结果消息
|
||||
///
|
||||
/// 当检测到截断时,生成一条引导模型分块重试的错误消息,
|
||||
/// 而不是直接返回解析错误。
|
||||
pub fn build_soft_failure_result(info: &TruncationInfo) -> String {
|
||||
match info.truncation_type {
|
||||
TruncationType::EmptyInput => {
|
||||
format!(
|
||||
"Tool call '{}' (id: {}) was truncated: the input was empty. \
|
||||
This usually means the response was cut off due to token limits. \
|
||||
Please retry with a shorter input or break the operation into smaller steps.",
|
||||
info.tool_name, info.tool_use_id
|
||||
)
|
||||
}
|
||||
TruncationType::IncompleteString => {
|
||||
format!(
|
||||
"Tool call '{}' (id: {}) was truncated: a string value was not properly closed. \
|
||||
The input appears to have been cut off mid-string. \
|
||||
Please retry with shorter content or split the operation into multiple calls.",
|
||||
info.tool_name, info.tool_use_id
|
||||
)
|
||||
}
|
||||
TruncationType::InvalidJson => {
|
||||
format!(
|
||||
"Tool call '{}' (id: {}) was truncated: the JSON input is incomplete \
|
||||
(unbalanced brackets). Please retry with a shorter input or break the \
|
||||
operation into smaller steps.",
|
||||
info.tool_name, info.tool_use_id
|
||||
)
|
||||
}
|
||||
TruncationType::MissingFields => {
|
||||
format!(
|
||||
"Tool call '{}' (id: {}) was truncated: required fields are missing. \
|
||||
Please retry with all required fields included.",
|
||||
info.tool_name, info.tool_use_id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查字符串中是否有未闭合的引号
|
||||
fn has_unclosed_string(s: &str) -> bool {
|
||||
let mut in_string = false;
|
||||
let mut escape_next = false;
|
||||
|
||||
for ch in s.chars() {
|
||||
if escape_next {
|
||||
escape_next = false;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\\' if in_string => {
|
||||
escape_next = true;
|
||||
}
|
||||
'"' => {
|
||||
in_string = !in_string;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
in_string
|
||||
}
|
||||
|
||||
/// 检查括号是否平衡
|
||||
fn are_brackets_balanced(s: &str) -> bool {
|
||||
let mut stack: Vec<char> = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut escape_next = false;
|
||||
|
||||
for ch in s.chars() {
|
||||
if escape_next {
|
||||
escape_next = false;
|
||||
continue;
|
||||
}
|
||||
if in_string {
|
||||
match ch {
|
||||
'\\' => escape_next = true,
|
||||
'"' => in_string = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'"' => in_string = true,
|
||||
'{' | '[' => stack.push(ch),
|
||||
'}' => {
|
||||
if stack.last() != Some(&'{') {
|
||||
return false;
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
']' => {
|
||||
if stack.last() != Some(&'[') {
|
||||
return false;
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
stack.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_empty_input() {
|
||||
let result = detect_truncation("Write", "tool-1", "");
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().truncation_type, TruncationType::EmptyInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_whitespace_only_input() {
|
||||
let result = detect_truncation("Write", "tool-1", " \n ");
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().truncation_type, TruncationType::EmptyInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_incomplete_string() {
|
||||
let result = detect_truncation("Write", "tool-1", r#"{"content": "hello world"#);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(
|
||||
result.unwrap().truncation_type,
|
||||
TruncationType::IncompleteString
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_unbalanced_brackets() {
|
||||
// 字符串已闭合但括号不平衡
|
||||
let result = detect_truncation("Write", "tool-1", r#"{"content": "hello","more": 123"#);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().truncation_type, TruncationType::InvalidJson);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_json_no_truncation() {
|
||||
let result = detect_truncation("Write", "tool-1", r#"{"content": "hello"}"#);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_empty_object() {
|
||||
let result = detect_truncation("Write", "tool-1", "{}");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_brackets() {
|
||||
let result = detect_truncation("Write", "tool-1", r#"{"a": {"b": [1, 2, {"c": 3}]}}"#);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escaped_quotes_in_string() {
|
||||
let result = detect_truncation("Write", "tool-1", r#"{"content": "say \"hello\""}"#);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_soft_failure_empty() {
|
||||
let info = TruncationInfo {
|
||||
truncation_type: TruncationType::EmptyInput,
|
||||
tool_name: "Write".to_string(),
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
raw_input: String::new(),
|
||||
};
|
||||
let msg = build_soft_failure_result(&info);
|
||||
assert!(msg.contains("truncated"));
|
||||
assert!(msg.contains("Write"));
|
||||
assert!(msg.contains("tool-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_soft_failure_incomplete_string() {
|
||||
let info = TruncationInfo {
|
||||
truncation_type: TruncationType::IncompleteString,
|
||||
tool_name: "Edit".to_string(),
|
||||
tool_use_id: "tool-2".to_string(),
|
||||
raw_input: r#"{"old_string": "hello"#.to_string(),
|
||||
};
|
||||
let msg = build_soft_failure_result(&info);
|
||||
assert!(msg.contains("string value was not properly closed"));
|
||||
}
|
||||
}
|
||||
315
src/anthropic/types.rs
Normal file
315
src/anthropic/types.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
//! Anthropic API 类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// === 错误响应 ===
|
||||
|
||||
/// API 错误响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: ErrorDetail,
|
||||
}
|
||||
|
||||
/// 错误详情
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorDetail {
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ErrorResponse {
|
||||
/// 创建新的错误响应
|
||||
pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error: ErrorDetail {
|
||||
error_type: error_type.into(),
|
||||
message: message.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建认证错误响应
|
||||
pub fn authentication_error() -> Self {
|
||||
Self::new("authentication_error", "Invalid API key")
|
||||
}
|
||||
}
|
||||
|
||||
// === Models 端点类型 ===
|
||||
|
||||
/// 模型信息
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Model {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: i64,
|
||||
pub owned_by: String,
|
||||
pub display_name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub model_type: String,
|
||||
pub max_tokens: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_length: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_completion_tokens: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<bool>,
|
||||
}
|
||||
|
||||
/// 模型列表响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ModelsResponse {
|
||||
pub object: String,
|
||||
pub data: Vec<Model>,
|
||||
}
|
||||
|
||||
// === Messages 端点类型 ===
|
||||
|
||||
/// 最大思考预算 tokens
|
||||
const MAX_BUDGET_TOKENS: i32 = 128_000;
|
||||
|
||||
/// Thinking 配置
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Thinking {
|
||||
#[serde(rename = "type")]
|
||||
pub thinking_type: String,
|
||||
#[serde(
|
||||
default = "default_budget_tokens",
|
||||
deserialize_with = "deserialize_budget_tokens"
|
||||
)]
|
||||
pub budget_tokens: i32,
|
||||
}
|
||||
|
||||
impl Thinking {
|
||||
/// 是否启用了 thinking(enabled 或 adaptive)
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.thinking_type == "enabled" || self.thinking_type == "adaptive"
|
||||
}
|
||||
}
|
||||
|
||||
fn default_budget_tokens() -> i32 {
|
||||
20000
|
||||
}
|
||||
fn deserialize_budget_tokens<'de, D>(deserializer: D) -> Result<i32, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = i32::deserialize(deserializer)?;
|
||||
Ok(value.min(MAX_BUDGET_TOKENS))
|
||||
}
|
||||
|
||||
/// OutputConfig 配置
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct OutputConfig {
|
||||
#[serde(default = "default_effort")]
|
||||
pub effort: String,
|
||||
}
|
||||
|
||||
fn default_effort() -> String {
|
||||
"high".to_string()
|
||||
}
|
||||
|
||||
/// Claude Code 请求中的 metadata
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Metadata {
|
||||
/// 用户 ID,格式如: user_xxx_account__session_0b4445e1-f5be-49e1-87ce-62bbc28ad705
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Messages 请求体
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MessagesRequest {
|
||||
pub model: String,
|
||||
/// 为 Anthropic API 兼容保留,实际不透传给 Kiro 上游
|
||||
pub max_tokens: i32,
|
||||
pub messages: Vec<Message>,
|
||||
#[serde(default)]
|
||||
pub stream: bool,
|
||||
#[serde(default, deserialize_with = "deserialize_system")]
|
||||
pub system: Option<Vec<SystemMessage>>,
|
||||
pub tools: Option<Vec<Tool>>,
|
||||
#[allow(dead_code)]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
pub thinking: Option<Thinking>,
|
||||
pub output_config: Option<OutputConfig>,
|
||||
/// Claude Code 请求中的 metadata,包含 session 信息
|
||||
pub metadata: Option<Metadata>,
|
||||
}
|
||||
|
||||
/// 反序列化 system 字段,支持字符串或数组格式
|
||||
fn deserialize_system<'de, D>(deserializer: D) -> Result<Option<Vec<SystemMessage>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
// 创建一个 visitor 来处理 string 或 array
|
||||
struct SystemVisitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for SystemVisitor {
|
||||
type Value = Option<Vec<SystemMessage>>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string or an array of system messages")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(Some(vec![SystemMessage {
|
||||
text: value.to_string(),
|
||||
}]))
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::SeqAccess<'de>,
|
||||
{
|
||||
let mut messages = Vec::new();
|
||||
while let Some(msg) = seq.next_element()? {
|
||||
messages.push(msg);
|
||||
}
|
||||
Ok(if messages.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(messages)
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_none<E>(self) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
serde::de::Deserialize::deserialize(deserializer)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(SystemVisitor)
|
||||
}
|
||||
|
||||
/// 消息
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Message {
|
||||
pub role: String,
|
||||
/// 可以是 string 或 ContentBlock 数组
|
||||
pub content: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 系统消息
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SystemMessage {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
///
|
||||
/// 支持两种格式:
|
||||
/// 1. 普通工具:{ name, description, input_schema }
|
||||
/// 2. WebSearch 工具:{ type: "web_search_20250305", name: "web_search", max_uses: 8 }
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Tool {
|
||||
/// 工具类型,如 "web_search_20250305"(可选,仅 WebSearch 工具)
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub tool_type: Option<String>,
|
||||
/// 工具名称
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// 工具描述(普通工具必需,WebSearch 工具可选)
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// 输入参数 schema(普通工具必需,WebSearch 工具无此字段)
|
||||
#[serde(default)]
|
||||
pub input_schema: HashMap<String, serde_json::Value>,
|
||||
/// 最大使用次数(仅 WebSearch 工具)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_uses: Option<i32>,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// 检查是否为 WebSearch 工具
|
||||
#[allow(dead_code)]
|
||||
pub fn is_web_search(&self) -> bool {
|
||||
self.tool_type
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.starts_with("web_search"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容块
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_use_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<ImageSource>,
|
||||
}
|
||||
|
||||
/// 图片数据源
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ImageSource {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: String,
|
||||
pub media_type: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
// === Count Tokens 端点类型 ===
|
||||
|
||||
/// Token 计数请求
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CountTokensRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "deserialize_system"
|
||||
)]
|
||||
pub system: Option<Vec<SystemMessage>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<Tool>>,
|
||||
}
|
||||
|
||||
/// Token 计数响应
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CountTokensResponse {
|
||||
pub input_tokens: i32,
|
||||
}
|
||||
|
||||
/// 根据模型名称获取上下文窗口大小
|
||||
///
|
||||
/// - Opus 4.6 和 Sonnet 4.6 系列: 1,000,000 tokens
|
||||
/// - 其他模型: 200,000 tokens
|
||||
pub fn get_context_window_size(model: &str) -> i32 {
|
||||
let model_lower = model.to_lowercase();
|
||||
if (model_lower.contains("opus") || model_lower.contains("sonnet"))
|
||||
&& (model_lower.contains("4-6") || model_lower.contains("4.6"))
|
||||
{
|
||||
1_000_000
|
||||
} else {
|
||||
200_000
|
||||
}
|
||||
}
|
||||
987
src/anthropic/websearch.rs
Normal file
987
src/anthropic/websearch.rs
Normal file
@@ -0,0 +1,987 @@
|
||||
//! WebSearch 工具处理模块
|
||||
//!
|
||||
//! 实现 Anthropic WebSearch 请求到 Kiro MCP 的转换和响应生成
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::{Stream, stream};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::stream::SseEvent;
|
||||
use super::types::{ErrorResponse, MessagesRequest};
|
||||
|
||||
const WEB_SEARCH_PREFIX: &str = "Perform a web search for the query: ";
|
||||
|
||||
/// MCP 请求
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct McpRequest {
|
||||
pub id: String,
|
||||
pub jsonrpc: String,
|
||||
pub method: String,
|
||||
pub params: McpParams,
|
||||
}
|
||||
|
||||
/// MCP 请求参数
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct McpParams {
|
||||
pub name: String,
|
||||
pub arguments: McpArguments,
|
||||
}
|
||||
|
||||
/// MCP 参数
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct McpArguments {
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
/// MCP 响应
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct McpResponse {
|
||||
pub error: Option<McpError>,
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
#[allow(dead_code)]
|
||||
pub jsonrpc: String,
|
||||
pub result: Option<McpResult>,
|
||||
}
|
||||
|
||||
/// MCP 错误
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct McpError {
|
||||
pub code: Option<i32>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// MCP 结果
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct McpResult {
|
||||
pub content: Vec<McpContent>,
|
||||
#[serde(rename = "isError")]
|
||||
#[allow(dead_code)]
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
/// MCP 内容
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct McpContent {
|
||||
#[serde(rename = "type")]
|
||||
pub content_type: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// WebSearch 搜索结果
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct WebSearchResults {
|
||||
pub results: Vec<WebSearchResult>,
|
||||
#[serde(rename = "totalResults")]
|
||||
pub total_results: Option<i32>,
|
||||
pub query: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 单个搜索结果
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct WebSearchResult {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub snippet: Option<String>,
|
||||
#[serde(rename = "publishedDate")]
|
||||
pub published_date: Option<i64>,
|
||||
pub id: Option<String>,
|
||||
pub domain: Option<String>,
|
||||
#[serde(rename = "maxVerbatimWordLimit")]
|
||||
pub max_verbatim_word_limit: Option<i32>,
|
||||
#[serde(rename = "publicDomain")]
|
||||
pub public_domain: Option<bool>,
|
||||
}
|
||||
|
||||
/// 检查请求是否包含 WebSearch 工具
|
||||
///
|
||||
/// 只要 tools 中出现 web_search(按 name 或 type 判断),就认为应走本地 WebSearch 处理。
|
||||
pub fn has_web_search_tool(req: &MessagesRequest) -> bool {
|
||||
req.tools.as_ref().is_some_and(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.any(|t| t.name == "web_search" || t.is_web_search())
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_choice_requests_web_search(req: &MessagesRequest) -> bool {
|
||||
let Some(choice) = req.tool_choice.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(obj) = choice.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Anthropic 常见形态:{"type":"tool","name":"web_search"}
|
||||
let name = obj
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| obj.get("tool_name").and_then(|v| v.as_str()));
|
||||
|
||||
if name != Some("web_search") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 若包含 type 字段,仅当 type=tool 才视为“强制调用”
|
||||
match obj.get("type").and_then(|v| v.as_str()) {
|
||||
Some("tool") => true,
|
||||
Some(_) => false,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_only_web_search_tool(req: &MessagesRequest) -> bool {
|
||||
req.tools.as_ref().is_some_and(|tools| {
|
||||
tools.len() == 1
|
||||
&& tools
|
||||
.first()
|
||||
.is_some_and(|t| t.name == "web_search" || t.is_web_search())
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_last_user_text(req: &MessagesRequest) -> Option<String> {
|
||||
let msg = req
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == "user")
|
||||
.or_else(|| req.messages.last())?;
|
||||
|
||||
match &msg.content {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Array(arr) => {
|
||||
let first_block = arr.first()?;
|
||||
if first_block.get("type")?.as_str()? == "text" {
|
||||
Some(first_block.get("text")?.as_str()?.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn request_explicit_web_search_prefix(req: &MessagesRequest) -> bool {
|
||||
extract_last_user_text(req)
|
||||
.map(|t| t.trim_start().starts_with(WEB_SEARCH_PREFIX))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 判断当前请求是否应走“本地 WebSearch”处理。
|
||||
///
|
||||
/// 注意:`tools` 里包含 `web_search` 仅代表“可用工具”,并不代表这次一定要执行搜索。
|
||||
/// 若不加额外条件,容易把普通对话/任务指令误当成搜索查询,导致 MCP 侧返回 -32602。
|
||||
pub fn should_handle_websearch_request(req: &MessagesRequest) -> bool {
|
||||
if !has_web_search_tool(req) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1) tool_choice 强制选择 web_search
|
||||
if tool_choice_requests_web_search(req) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2) 兼容旧客户端:仅提供 web_search 单工具时,视为“纯 WebSearch 请求”
|
||||
if is_only_web_search_tool(req) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3) 兼容 Claude Code 风格前缀
|
||||
request_explicit_web_search_prefix(req)
|
||||
}
|
||||
|
||||
/// 从请求的 tools 列表中移除 web_search 工具。
|
||||
///
|
||||
/// 当请求包含混合工具(web_search + 其他工具)时,剔除 web_search 后转发上游。
|
||||
/// 若剔除后 tools 为空,则设为 None。
|
||||
pub fn strip_web_search_tools(req: &mut MessagesRequest) {
|
||||
if let Some(tools) = req.tools.as_mut() {
|
||||
tools.retain(|t| t.name != "web_search" && !t.is_web_search());
|
||||
if tools.is_empty() {
|
||||
req.tools = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从消息中提取搜索查询
|
||||
///
|
||||
/// 读取 messages 中最后一条 user 消息的第一个内容块(更符合多轮对话场景)
|
||||
/// 并去除 "Perform a web search for the query: " 前缀
|
||||
pub fn extract_search_query(req: &MessagesRequest) -> Option<String> {
|
||||
// 优先取最后一条 user 消息,否则回退到最后一条消息
|
||||
let msg = req
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == "user")
|
||||
.or_else(|| req.messages.last())?;
|
||||
|
||||
// 提取文本内容
|
||||
let text = match &msg.content {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Array(arr) => {
|
||||
// 获取第一个内容块
|
||||
let first_block = arr.first()?;
|
||||
if first_block.get("type")?.as_str()? == "text" {
|
||||
first_block.get("text")?.as_str()?.to_string()
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// 去除前缀 "Perform a web search for the query: "
|
||||
let query = text
|
||||
.strip_prefix(WEB_SEARCH_PREFIX)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(text);
|
||||
|
||||
let query = query.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if query.is_empty() { None } else { Some(query) }
|
||||
}
|
||||
|
||||
/// 生成22位大小写字母和数字的随机字符串
|
||||
fn generate_random_id_22() -> String {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
(0..22)
|
||||
.map(|_| {
|
||||
let idx = fastrand::usize(..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 生成8位小写字母和数字的随机字符串
|
||||
fn generate_random_id_8() -> String {
|
||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
(0..8)
|
||||
.map(|_| {
|
||||
let idx = fastrand::usize(..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 创建 MCP 请求
|
||||
///
|
||||
/// ID 格式: web_search_tooluse_{22位随机}_{毫秒时间戳}_{8位随机}
|
||||
pub fn create_mcp_request(query: &str) -> (String, McpRequest) {
|
||||
let random_22 = generate_random_id_22();
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
let random_8 = generate_random_id_8();
|
||||
|
||||
let request_id = format!(
|
||||
"web_search_tooluse_{}_{}_{}",
|
||||
random_22, timestamp, random_8
|
||||
);
|
||||
|
||||
// tool_use_id 使用相同格式
|
||||
let tool_use_id = format!(
|
||||
"srvtoolu_{}",
|
||||
&Uuid::new_v4().to_string().replace('-', "")[..32]
|
||||
);
|
||||
|
||||
let request = McpRequest {
|
||||
id: request_id,
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: "tools/call".to_string(),
|
||||
params: McpParams {
|
||||
name: "web_search".to_string(),
|
||||
arguments: McpArguments {
|
||||
query: query.to_string(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(tool_use_id, request)
|
||||
}
|
||||
|
||||
/// 解析 MCP 响应中的搜索结果
|
||||
pub fn parse_search_results(mcp_response: &McpResponse) -> Option<WebSearchResults> {
|
||||
let result = mcp_response.result.as_ref()?;
|
||||
let content = result.content.first()?;
|
||||
|
||||
if content.content_type != "text" {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str(&content.text).ok()
|
||||
}
|
||||
|
||||
/// 生成 WebSearch SSE 响应流
|
||||
pub fn create_websearch_sse_stream(
|
||||
model: String,
|
||||
query: String,
|
||||
tool_use_id: String,
|
||||
search_results: Option<WebSearchResults>,
|
||||
input_tokens: i32,
|
||||
) -> impl Stream<Item = Result<Bytes, Infallible>> {
|
||||
let events =
|
||||
generate_websearch_events(&model, &query, &tool_use_id, search_results, input_tokens);
|
||||
|
||||
stream::iter(
|
||||
events
|
||||
.into_iter()
|
||||
.map(|e| Ok(Bytes::from(e.to_sse_string()))),
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成 WebSearch SSE 事件序列
|
||||
fn generate_websearch_events(
|
||||
model: &str,
|
||||
query: &str,
|
||||
tool_use_id: &str,
|
||||
search_results: Option<WebSearchResults>,
|
||||
input_tokens: i32,
|
||||
) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
let message_id = format!("msg_{}", &Uuid::new_v4().to_string().replace('-', "")[..24]);
|
||||
|
||||
// 1. message_start
|
||||
events.push(SseEvent::new(
|
||||
"message_start",
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": [],
|
||||
"stop_reason": null,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0
|
||||
}
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
// 2. content_block_start (text - 搜索决策说明, index 0)
|
||||
let decision_text = format!("I'll search for \"{}\".", query);
|
||||
events.push(SseEvent::new(
|
||||
"content_block_start",
|
||||
json!({
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
events.push(SseEvent::new(
|
||||
"content_block_delta",
|
||||
json!({
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": decision_text
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
events.push(SseEvent::new(
|
||||
"content_block_stop",
|
||||
json!({
|
||||
"type": "content_block_stop",
|
||||
"index": 0
|
||||
}),
|
||||
));
|
||||
|
||||
// 3. content_block_start (server_tool_use, index 1)
|
||||
// server_tool_use 是服务端工具,input 在 content_block_start 中一次性完整发送,
|
||||
// 不像客户端 tool_use 需要通过 input_json_delta 增量传输。
|
||||
events.push(SseEvent::new(
|
||||
"content_block_start",
|
||||
json!({
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {
|
||||
"id": tool_use_id,
|
||||
"type": "server_tool_use",
|
||||
"name": "web_search",
|
||||
"input": {"query": query}
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
// 4. content_block_stop (server_tool_use)
|
||||
events.push(SseEvent::new(
|
||||
"content_block_stop",
|
||||
json!({
|
||||
"type": "content_block_stop",
|
||||
"index": 1
|
||||
}),
|
||||
));
|
||||
|
||||
// 5. content_block_start (web_search_tool_result, index 2)
|
||||
// 官方 API 的 web_search_tool_result 没有 tool_use_id 字段
|
||||
let search_content = if let Some(ref results) = search_results {
|
||||
results
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let page_age = r.published_date.and_then(|ms| {
|
||||
chrono::DateTime::from_timestamp_millis(ms)
|
||||
.map(|dt| dt.format("%B %-d, %Y").to_string())
|
||||
});
|
||||
json!({
|
||||
"type": "web_search_result",
|
||||
"title": r.title,
|
||||
"url": r.url,
|
||||
"encrypted_content": r.snippet.clone().unwrap_or_default(),
|
||||
"page_age": page_age
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
events.push(SseEvent::new(
|
||||
"content_block_start",
|
||||
json!({
|
||||
"type": "content_block_start",
|
||||
"index": 2,
|
||||
"content_block": {
|
||||
"type": "web_search_tool_result",
|
||||
"content": search_content
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
// 6. content_block_stop (web_search_tool_result)
|
||||
events.push(SseEvent::new(
|
||||
"content_block_stop",
|
||||
json!({
|
||||
"type": "content_block_stop",
|
||||
"index": 2
|
||||
}),
|
||||
));
|
||||
|
||||
// 7. content_block_start (text, index 3)
|
||||
events.push(SseEvent::new(
|
||||
"content_block_start",
|
||||
json!({
|
||||
"type": "content_block_start",
|
||||
"index": 3,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
// 8. content_block_delta (text_delta) - 生成搜索结果摘要
|
||||
let summary = generate_search_summary(query, &search_results);
|
||||
|
||||
// 分块发送文本
|
||||
let chunk_size = 100;
|
||||
for chunk in summary.chars().collect::<Vec<_>>().chunks(chunk_size) {
|
||||
let text: String = chunk.iter().collect();
|
||||
events.push(SseEvent::new(
|
||||
"content_block_delta",
|
||||
json!({
|
||||
"type": "content_block_delta",
|
||||
"index": 3,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": text
|
||||
}
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// 9. content_block_stop (text)
|
||||
events.push(SseEvent::new(
|
||||
"content_block_stop",
|
||||
json!({
|
||||
"type": "content_block_stop",
|
||||
"index": 3
|
||||
}),
|
||||
));
|
||||
|
||||
// 10. message_delta
|
||||
// 官方 API 的 message_delta.delta 中没有 stop_sequence 字段
|
||||
let output_tokens = (summary.len() as i32 + 3) / 4; // 简单估算
|
||||
events.push(SseEvent::new(
|
||||
"message_delta",
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": "end_turn"
|
||||
},
|
||||
"usage": {
|
||||
"output_tokens": output_tokens,
|
||||
"server_tool_use": {
|
||||
"web_search_requests": 1
|
||||
}
|
||||
}
|
||||
}),
|
||||
));
|
||||
|
||||
// 11. message_stop
|
||||
events.push(SseEvent::new(
|
||||
"message_stop",
|
||||
json!({
|
||||
"type": "message_stop"
|
||||
}),
|
||||
));
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
/// 生成搜索结果摘要
|
||||
fn generate_search_summary(query: &str, results: &Option<WebSearchResults>) -> String {
|
||||
let mut summary = format!("Here are the search results for \"{}\":\n\n", query);
|
||||
|
||||
if let Some(results) = results {
|
||||
for (i, result) in results.results.iter().enumerate() {
|
||||
summary.push_str(&format!("{}. **{}**\n", i + 1, result.title));
|
||||
if let Some(ref snippet) = result.snippet {
|
||||
// 截断过长的摘要(安全处理 UTF-8 多字节字符)
|
||||
let truncated = match snippet.char_indices().nth(200) {
|
||||
Some((idx, _)) => format!("{}...", &snippet[..idx]),
|
||||
None => snippet.clone(),
|
||||
};
|
||||
summary.push_str(&format!(" {}\n", truncated));
|
||||
}
|
||||
summary.push_str(&format!(" Source: {}\n\n", result.url));
|
||||
}
|
||||
} else {
|
||||
summary.push_str("No results found.\n");
|
||||
}
|
||||
|
||||
summary.push_str("\nPlease note that these are web search results and may not be fully accurate or up-to-date.");
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
/// 处理 WebSearch 请求
|
||||
pub async fn handle_websearch_request(
|
||||
provider: std::sync::Arc<crate::kiro::provider::KiroProvider>,
|
||||
payload: &MessagesRequest,
|
||||
input_tokens: i32,
|
||||
) -> Response {
|
||||
// 1. 提取搜索查询
|
||||
let query = match extract_search_query(payload) {
|
||||
Some(q) => q,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse::new(
|
||||
"invalid_request_error",
|
||||
"无法从消息中提取搜索查询",
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(query = %query, "处理 WebSearch 请求");
|
||||
|
||||
// 2. 创建 MCP 请求
|
||||
let (tool_use_id, mcp_request) = create_mcp_request(&query);
|
||||
|
||||
// 3. 调用 Kiro MCP API
|
||||
let search_results = match call_mcp_api(&provider, &mcp_request).await {
|
||||
Ok(response) => parse_search_results(&response),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP API 调用失败: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 生成 SSE 响应
|
||||
let model = payload.model.clone();
|
||||
if payload.stream {
|
||||
let stream =
|
||||
create_websearch_sse_stream(model, query, tool_use_id, search_results, input_tokens);
|
||||
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.header(header::CONNECTION, "keep-alive")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let summary = generate_search_summary(&query, &search_results);
|
||||
let search_content = if let Some(ref results) = search_results {
|
||||
results
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"type": "web_search_result",
|
||||
"title": r.title,
|
||||
"url": r.url,
|
||||
"encrypted_content": r.snippet.clone().unwrap_or_default(),
|
||||
"page_age": null
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let output_tokens = (summary.len() as i32 + 3) / 4; // 简单估算
|
||||
|
||||
let response_body = json!({
|
||||
"id": format!("msg_{}", &Uuid::new_v4().to_string().replace('-', "")[..24]),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": [
|
||||
{
|
||||
"type": "server_tool_use",
|
||||
"id": tool_use_id,
|
||||
"name": "web_search",
|
||||
"input": { "query": query }
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": search_content
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": summary
|
||||
}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
}
|
||||
});
|
||||
|
||||
(StatusCode::OK, Json(response_body)).into_response()
|
||||
}
|
||||
|
||||
/// 调用 Kiro MCP API
|
||||
async fn call_mcp_api(
|
||||
provider: &crate::kiro::provider::KiroProvider,
|
||||
request: &McpRequest,
|
||||
) -> anyhow::Result<McpResponse> {
|
||||
let request_body = serde_json::to_string(request)?;
|
||||
|
||||
tracing::debug!("MCP request: {}", request_body);
|
||||
|
||||
let response = provider.call_mcp(&request_body).await?;
|
||||
|
||||
let body = response.text().await?;
|
||||
tracing::debug!("MCP response: {}", body);
|
||||
|
||||
let mcp_response: McpResponse = serde_json::from_str(&body)?;
|
||||
|
||||
if let Some(ref error) = mcp_response.error {
|
||||
anyhow::bail!(
|
||||
"MCP error: {} - {}",
|
||||
error.code.unwrap_or(-1),
|
||||
error.message.as_deref().unwrap_or("Unknown error")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(mcp_response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_has_web_search_tool_only_one() {
|
||||
use crate::anthropic::types::{Message, Tool};
|
||||
|
||||
let req = MessagesRequest {
|
||||
model: "claude-sonnet-4".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!("test"),
|
||||
}],
|
||||
stream: true,
|
||||
system: None,
|
||||
tools: Some(vec![Tool {
|
||||
tool_type: Some("web_search_20250305".to_string()),
|
||||
name: "web_search".to_string(),
|
||||
description: String::new(),
|
||||
input_schema: Default::default(),
|
||||
max_uses: Some(8),
|
||||
}]),
|
||||
tool_choice: None,
|
||||
thinking: None,
|
||||
output_config: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
assert!(has_web_search_tool(&req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_web_search_tool_when_multiple_tools() {
|
||||
use crate::anthropic::types::{Message, Tool};
|
||||
|
||||
let req = MessagesRequest {
|
||||
model: "claude-sonnet-4".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!("test"),
|
||||
}],
|
||||
stream: true,
|
||||
system: None,
|
||||
tools: Some(vec![
|
||||
Tool {
|
||||
tool_type: Some("web_search_20250305".to_string()),
|
||||
name: "web_search".to_string(),
|
||||
description: String::new(),
|
||||
input_schema: Default::default(),
|
||||
max_uses: Some(8),
|
||||
},
|
||||
Tool {
|
||||
tool_type: None,
|
||||
name: "other_tool".to_string(),
|
||||
description: "Other tool".to_string(),
|
||||
input_schema: Default::default(),
|
||||
max_uses: None,
|
||||
},
|
||||
]),
|
||||
tool_choice: None,
|
||||
thinking: None,
|
||||
output_config: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
assert!(has_web_search_tool(&req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_web_search_tool_when_name_missing_but_type_matches() {
|
||||
use crate::anthropic::types::{Message, Tool};
|
||||
|
||||
let req = MessagesRequest {
|
||||
model: "claude-sonnet-4".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!("test"),
|
||||
}],
|
||||
stream: true,
|
||||
system: None,
|
||||
tools: Some(vec![Tool {
|
||||
tool_type: Some("web_search_20250305".to_string()),
|
||||
name: "".to_string(), // 模拟客户端只传 type 的情况
|
||||
description: String::new(),
|
||||
input_schema: Default::default(),
|
||||
max_uses: Some(8),
|
||||
}]),
|
||||
tool_choice: None,
|
||||
thinking: None,
|
||||
output_config: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
assert!(has_web_search_tool(&req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_query_with_prefix() {
|
||||
use crate::anthropic::types::Message;
|
||||
|
||||
let req = MessagesRequest {
|
||||
model: "claude-sonnet-4".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!([{
|
||||
"type": "text",
|
||||
"text": "Perform a web search for the query: rust latest version 2026"
|
||||
}]),
|
||||
}],
|
||||
stream: true,
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
thinking: None,
|
||||
output_config: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let query = extract_search_query(&req);
|
||||
// 前缀应该被去除
|
||||
assert_eq!(query, Some("rust latest version 2026".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_query_plain_text() {
|
||||
use crate::anthropic::types::Message;
|
||||
|
||||
let req = MessagesRequest {
|
||||
model: "claude-sonnet-4".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!("What is the weather today?"),
|
||||
}],
|
||||
stream: true,
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
thinking: None,
|
||||
output_config: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let query = extract_search_query(&req);
|
||||
assert_eq!(query, Some("What is the weather today?".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_search_query_uses_last_user_message() {
|
||||
use crate::anthropic::types::Message;
|
||||
|
||||
let req = MessagesRequest {
|
||||
model: "claude-sonnet-4".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!("not a query"),
|
||||
},
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: serde_json::json!("ok"),
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!([{
|
||||
"type": "text",
|
||||
"text": "Perform a web search for the query: rust"
|
||||
}]),
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
thinking: None,
|
||||
output_config: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let query = extract_search_query(&req);
|
||||
assert_eq!(query, Some("rust".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_mcp_request() {
|
||||
let (tool_use_id, request) = create_mcp_request("test query");
|
||||
|
||||
assert!(tool_use_id.starts_with("srvtoolu_"));
|
||||
assert_eq!(request.jsonrpc, "2.0");
|
||||
assert_eq!(request.method, "tools/call");
|
||||
assert_eq!(request.params.name, "web_search");
|
||||
assert_eq!(request.params.arguments.query, "test query");
|
||||
|
||||
// 验证 ID 格式: web_search_tooluse_{22位}_{时间戳}_{8位}
|
||||
assert!(request.id.starts_with("web_search_tooluse_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_request_id_format() {
|
||||
let (_, request) = create_mcp_request("test");
|
||||
|
||||
// 格式: web_search_tooluse_{22位}_{毫秒时间戳}_{8位}
|
||||
let id = &request.id;
|
||||
assert!(id.starts_with("web_search_tooluse_"));
|
||||
|
||||
let suffix = &id["web_search_tooluse_".len()..];
|
||||
let parts: Vec<&str> = suffix.split('_').collect();
|
||||
assert_eq!(parts.len(), 3, "应该有3个部分: 22位随机_时间戳_8位随机");
|
||||
|
||||
// 第一部分: 22位大小写字母和数字
|
||||
assert_eq!(parts[0].len(), 22);
|
||||
assert!(parts[0].chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
|
||||
// 第二部分: 毫秒时间戳
|
||||
assert!(parts[1].parse::<i64>().is_ok());
|
||||
|
||||
// 第三部分: 8位小写字母和数字
|
||||
assert_eq!(parts[2].len(), 8);
|
||||
assert!(
|
||||
parts[2]
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_search_results() {
|
||||
let response = McpResponse {
|
||||
error: None,
|
||||
id: "test_id".to_string(),
|
||||
jsonrpc: "2.0".to_string(),
|
||||
result: Some(McpResult {
|
||||
content: vec![McpContent {
|
||||
content_type: "text".to_string(),
|
||||
text: r#"{"results":[{"title":"Test","url":"https://example.com","snippet":"Test snippet"}],"totalResults":1}"#.to_string(),
|
||||
}],
|
||||
is_error: false,
|
||||
}),
|
||||
};
|
||||
|
||||
let results = parse_search_results(&response);
|
||||
assert!(results.is_some());
|
||||
let results = results.unwrap();
|
||||
assert_eq!(results.results.len(), 1);
|
||||
assert_eq!(results.results[0].title, "Test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_search_summary() {
|
||||
let results = WebSearchResults {
|
||||
results: vec![WebSearchResult {
|
||||
title: "Test Result".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
snippet: Some("This is a test snippet".to_string()),
|
||||
published_date: None,
|
||||
id: None,
|
||||
domain: None,
|
||||
max_verbatim_word_limit: None,
|
||||
public_domain: None,
|
||||
}],
|
||||
total_results: Some(1),
|
||||
query: Some("test".to_string()),
|
||||
error: None,
|
||||
};
|
||||
|
||||
let summary = generate_search_summary("test", &Some(results));
|
||||
|
||||
assert!(summary.contains("Test Result"));
|
||||
assert!(summary.contains("https://example.com"));
|
||||
assert!(summary.contains("This is a test snippet"));
|
||||
}
|
||||
}
|
||||
41
src/common/auth.rs
Normal file
41
src/common/auth.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! 公共认证工具函数
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, header},
|
||||
};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// 从请求中提取 API Key
|
||||
///
|
||||
/// 支持两种认证方式:
|
||||
/// - `x-api-key` header
|
||||
/// - `Authorization: Bearer <token>` header
|
||||
pub fn extract_api_key(request: &Request<Body>) -> Option<String> {
|
||||
// 优先检查 x-api-key
|
||||
if let Some(key) = request
|
||||
.headers()
|
||||
.get("x-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
// 其次检查 Authorization: Bearer
|
||||
request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// 常量时间字符串比较,防止时序攻击
|
||||
///
|
||||
/// 无论字符串内容如何,比较所需的时间都是恒定的,
|
||||
/// 这可以防止攻击者通过测量响应时间来猜测 API Key。
|
||||
///
|
||||
/// 使用经过安全审计的 `subtle` crate 实现
|
||||
pub fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
a.as_bytes().ct_eq(b.as_bytes()).into()
|
||||
}
|
||||
5
src/common/mod.rs
Normal file
5
src/common/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! 公共工具模块
|
||||
|
||||
pub mod auth;
|
||||
pub mod redact;
|
||||
pub mod utf8;
|
||||
97
src/common/redact.rs
Normal file
97
src/common/redact.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! 日志脱敏工具
|
||||
//!
|
||||
//! 目标:避免在日志中输出敏感信息(Token、密钥、密码等)。
|
||||
|
||||
#![allow(dead_code)] // 工具模块,函数将在后续被调用
|
||||
|
||||
/// 统一的脱敏占位符
|
||||
pub const REDACTED: &str = "<redacted>";
|
||||
|
||||
/// 将 `Option<String>` 映射为“是否存在”的脱敏表示:
|
||||
/// - `Some(_)` -> `Some("<redacted>")`
|
||||
/// - `None` -> `None`
|
||||
#[inline]
|
||||
pub fn redact_opt_string(value: &Option<String>) -> Option<&'static str> {
|
||||
value.as_ref().map(|_| REDACTED)
|
||||
}
|
||||
|
||||
/// 脱敏邮箱:
|
||||
/// - `abc@example.com` -> `a***@example.com`
|
||||
/// - 无法解析时返回 `<redacted>`
|
||||
pub fn mask_email(email: &str) -> String {
|
||||
let (local, domain) = match email.split_once('@') {
|
||||
Some((l, d)) if !l.is_empty() && !d.is_empty() => (l, d),
|
||||
_ => return REDACTED.to_string(),
|
||||
};
|
||||
|
||||
// 保留首个完整字符(支持多字节 UTF-8)
|
||||
let first_char_end = local
|
||||
.char_indices()
|
||||
.nth(1)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(local.len());
|
||||
format!("{}***@{}", &local[..first_char_end], domain)
|
||||
}
|
||||
|
||||
/// 脱敏 AWS ARN 中的 account id(第 5 段):
|
||||
/// `arn:aws:service:region:123456789012:resource` -> `arn:aws:service:region:***:resource`
|
||||
pub fn mask_aws_account_id_in_arn(arn: &str) -> String {
|
||||
let mut parts = arn.splitn(6, ':').collect::<Vec<_>>();
|
||||
if parts.len() != 6 || parts[0] != "arn" {
|
||||
return arn.to_string();
|
||||
}
|
||||
|
||||
if !parts[4].is_empty() {
|
||||
parts[4] = "***";
|
||||
}
|
||||
|
||||
parts.join(":")
|
||||
}
|
||||
|
||||
/// 脱敏 URL 中的 userinfo(仅当包含 `scheme://...@`):
|
||||
/// - `http://user:pass@host:port` -> `http://user:***@host:port`
|
||||
/// - `http://user@host` -> `http://***@host`
|
||||
pub fn mask_url_userinfo(url: &str) -> String {
|
||||
let Some(scheme_end) = url.find("://").map(|i| i + 3) else {
|
||||
return url.to_string();
|
||||
};
|
||||
|
||||
// authority 结束于首个 '/'、'?'、'#' 或字符串末尾
|
||||
let authority_end = url[scheme_end..]
|
||||
.find(['/', '?', '#'])
|
||||
.map(|i| scheme_end + i)
|
||||
.unwrap_or(url.len());
|
||||
|
||||
// '@' 必须在 authority 内才是 userinfo 分隔符
|
||||
let Some(at_pos) = url[scheme_end..authority_end]
|
||||
.find('@')
|
||||
.map(|i| scheme_end + i)
|
||||
else {
|
||||
return url.to_string();
|
||||
};
|
||||
|
||||
let userinfo = &url[scheme_end..at_pos];
|
||||
if userinfo.is_empty() {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
let masked_userinfo = match userinfo.split_once(':') {
|
||||
Some((user, _pass)) if !user.is_empty() => format!("{}:***", user),
|
||||
_ => "***".to_string(),
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}{}{}",
|
||||
&url[..scheme_end],
|
||||
masked_userinfo,
|
||||
&url[at_pos..]
|
||||
)
|
||||
}
|
||||
|
||||
/// 脱敏 User-Agent 中的 machine_id(常见形态为以 `-<machine_id>` 结尾)。
|
||||
pub fn mask_user_agent_machine_id(value: &str) -> String {
|
||||
let Some(pos) = value.rfind('-') else {
|
||||
return value.to_string();
|
||||
};
|
||||
format!("{}{}", &value[..(pos + 1)], REDACTED)
|
||||
}
|
||||
46
src/common/utf8.rs
Normal file
46
src/common/utf8.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! UTF-8 字符边界工具
|
||||
//!
|
||||
//! 目前标准库的 `str::floor_char_boundary()` 仍是 nightly-only。
|
||||
//! 为了在 stable 上安全按字节索引截断 UTF-8 字符串,这里提供等价实现。
|
||||
|
||||
/// 返回不大于 `idx` 的最大 UTF-8 字符边界(byte index)。
|
||||
///
|
||||
/// - 若 `idx >= s.len()`,返回 `s.len()`
|
||||
/// - 否则向左回退到最近的 `is_char_boundary()` 位置
|
||||
#[inline]
|
||||
pub fn floor_char_boundary(s: &str, idx: usize) -> usize {
|
||||
if idx >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
|
||||
let mut i = idx;
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_floor_char_boundary_ascii() {
|
||||
let s = "abcdef";
|
||||
assert_eq!(floor_char_boundary(s, 3), 3);
|
||||
assert_eq!(floor_char_boundary(s, 999), s.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_floor_char_boundary_utf8() {
|
||||
let s = "你好ab";
|
||||
// "你" 占 3 字节;idx=1/2 应回退到 0
|
||||
assert_eq!(floor_char_boundary(s, 1), 0);
|
||||
assert_eq!(floor_char_boundary(s, 2), 0);
|
||||
assert_eq!(floor_char_boundary(s, 3), 3);
|
||||
// idx 落在第二个汉字中间(3..6)
|
||||
assert_eq!(floor_char_boundary(s, 4), 3);
|
||||
assert_eq!(floor_char_boundary(s, 5), 3);
|
||||
assert_eq!(floor_char_boundary(s, 6), 6);
|
||||
}
|
||||
}
|
||||
210
src/debug.rs
Normal file
210
src/debug.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! 调试工具模块
|
||||
//!
|
||||
//! 提供 hex 打印和 CRC 调试等功能
|
||||
|
||||
use crate::kiro::model::events::Event;
|
||||
use std::io::Write;
|
||||
|
||||
/// 打印 hex 数据 (类似 xxd 格式)
|
||||
pub fn print_hex(data: &[u8]) {
|
||||
for (i, chunk) in data.chunks(16).enumerate() {
|
||||
// 打印偏移
|
||||
print!("{:08x}: ", i * 16);
|
||||
|
||||
// 打印 hex
|
||||
for (j, byte) in chunk.iter().enumerate() {
|
||||
if j == 8 {
|
||||
print!(" ");
|
||||
}
|
||||
print!("{:02x} ", byte);
|
||||
}
|
||||
|
||||
// 补齐空格
|
||||
let padding = 16 - chunk.len();
|
||||
for j in 0..padding {
|
||||
if chunk.len() + j == 8 {
|
||||
print!(" ");
|
||||
}
|
||||
print!(" ");
|
||||
}
|
||||
|
||||
// 打印 ASCII
|
||||
print!(" |");
|
||||
for byte in chunk {
|
||||
if *byte >= 0x20 && *byte < 0x7f {
|
||||
print!("{}", *byte as char);
|
||||
} else {
|
||||
print!(".");
|
||||
}
|
||||
}
|
||||
println!("|");
|
||||
}
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
/// 调试 CRC 计算 - 分析 AWS Event Stream 帧的 CRC
|
||||
pub fn debug_crc(data: &[u8]) {
|
||||
if data.len() < 12 {
|
||||
println!("[CRC 调试] 数据不足 12 字节");
|
||||
return;
|
||||
}
|
||||
|
||||
use crc::{Crc, CRC_32_BZIP2, CRC_32_ISO_HDLC, CRC_32_ISCSI, CRC_32_JAMCRC};
|
||||
|
||||
let total_length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
|
||||
let header_length = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
|
||||
let prelude_crc = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
|
||||
|
||||
println!("\n[CRC 调试]");
|
||||
println!(" total_length: {} (0x{:08x})", total_length, total_length);
|
||||
println!(
|
||||
" header_length: {} (0x{:08x})",
|
||||
header_length, header_length
|
||||
);
|
||||
println!(" prelude_crc (from data): 0x{:08x}", prelude_crc);
|
||||
|
||||
// 测试各种 CRC32 变种
|
||||
let crc32c: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
|
||||
let crc32_iso: Crc<u32> = Crc::<u32>::new(&CRC_32_ISO_HDLC);
|
||||
let crc32_bzip2: Crc<u32> = Crc::<u32>::new(&CRC_32_BZIP2);
|
||||
let crc32_jamcrc: Crc<u32> = Crc::<u32>::new(&CRC_32_JAMCRC);
|
||||
|
||||
let prelude = &data[..8];
|
||||
|
||||
println!(" CRC32C (ISCSI): 0x{:08x}", crc32c.checksum(prelude));
|
||||
println!(
|
||||
" CRC32 ISO-HDLC: 0x{:08x} {}",
|
||||
crc32_iso.checksum(prelude),
|
||||
if crc32_iso.checksum(prelude) == prelude_crc {
|
||||
"<-- MATCH"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
println!(" CRC32 BZIP2: 0x{:08x}", crc32_bzip2.checksum(prelude));
|
||||
println!(
|
||||
" CRC32 JAMCRC: 0x{:08x}",
|
||||
crc32_jamcrc.checksum(prelude)
|
||||
);
|
||||
|
||||
// 打印前 8 字节
|
||||
print!(" 前 8 字节: ");
|
||||
for byte in prelude {
|
||||
print!("{:02x} ", byte);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
/// 打印帧摘要信息
|
||||
pub fn print_frame_summary(data: &[u8]) {
|
||||
if data.len() < 12 {
|
||||
println!("[帧摘要] 数据不足");
|
||||
return;
|
||||
}
|
||||
|
||||
let total_length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
let header_length = u32::from_be_bytes([data[4], data[5], data[6], data[7]]) as usize;
|
||||
|
||||
println!("\n[帧摘要]");
|
||||
println!(" 总长度: {} 字节", total_length);
|
||||
println!(" 头部长度: {} 字节", header_length);
|
||||
println!(" Payload 长度: {} 字节", total_length.saturating_sub(12 + header_length + 4));
|
||||
println!(" 数据可用: {} 字节", data.len());
|
||||
|
||||
if data.len() >= total_length {
|
||||
println!(" 状态: 完整帧");
|
||||
} else {
|
||||
println!(
|
||||
" 状态: 不完整 (缺少 {} 字节)",
|
||||
total_length - data.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 详细打印事件 (调试格式,包含事件类型和完整数据)
|
||||
pub fn print_event_verbose(event: &Event) {
|
||||
match event {
|
||||
Event::AssistantResponse(e) => {
|
||||
println!("\n[事件] AssistantResponse");
|
||||
println!(" content: {:?}", e.content());
|
||||
}
|
||||
Event::ToolUse(e) => {
|
||||
println!("\n[事件] ToolUse");
|
||||
println!(" name: {:?}", e.name());
|
||||
println!(" tool_use_id: {:?}", e.tool_use_id());
|
||||
println!(" input: {:?}", e.input());
|
||||
println!(" stop: {}", e.is_complete());
|
||||
}
|
||||
Event::Metering(e) => {
|
||||
println!("\n[事件] Metering");
|
||||
println!(" unit: {:?}", e.unit);
|
||||
println!(" unit_plural: {:?}", e.unit_plural);
|
||||
println!(" usage: {}", e.usage);
|
||||
}
|
||||
Event::ContextUsage(e) => {
|
||||
println!("\n[事件] ContextUsage");
|
||||
println!(" context_usage_percentage: {}", e.context_usage_percentage);
|
||||
}
|
||||
Event::Unknown { event_type, payload } => {
|
||||
println!("\n[事件] Unknown");
|
||||
println!(" event_type: {:?}", event_type);
|
||||
println!(" payload ({} bytes):", payload.len());
|
||||
print_hex(payload);
|
||||
}
|
||||
Event::Error {
|
||||
error_code,
|
||||
error_message,
|
||||
} => {
|
||||
println!("\n[事件] Error");
|
||||
println!(" error_code: {:?}", error_code);
|
||||
println!(" error_message: {:?}", error_message);
|
||||
}
|
||||
Event::Exception {
|
||||
exception_type,
|
||||
message,
|
||||
} => {
|
||||
println!("\n[事件] Exception");
|
||||
println!(" exception_type: {:?}", exception_type);
|
||||
println!(" message: {:?}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 简洁打印事件 (用于正常输出)
|
||||
pub fn print_event(event: &Event) {
|
||||
match event {
|
||||
Event::AssistantResponse(e) => {
|
||||
// 实时打印助手响应,不换行
|
||||
print!("{}", e.content());
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
Event::ToolUse(e) => {
|
||||
println!("\n[工具调用] {} (id: {})", e.name(), e.tool_use_id());
|
||||
println!(" 输入: {}", e.input());
|
||||
if e.is_complete() {
|
||||
println!(" [调用结束]");
|
||||
}
|
||||
}
|
||||
Event::Metering(e) => {
|
||||
println!("\n[计费] {}", e);
|
||||
}
|
||||
Event::ContextUsage(e) => {
|
||||
println!("\n[上下文使用率] {}", e);
|
||||
}
|
||||
Event::Unknown { event_type, .. } => {
|
||||
println!("\n[未知事件] {}", event_type);
|
||||
}
|
||||
Event::Error {
|
||||
error_code,
|
||||
error_message,
|
||||
} => {
|
||||
println!("\n[错误] {}: {}", error_code, error_message);
|
||||
}
|
||||
Event::Exception {
|
||||
exception_type,
|
||||
message,
|
||||
} => {
|
||||
println!("\n[异常] {}: {}", exception_type, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/http_client.rs
Normal file
105
src/http_client.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! HTTP Client 构建模块
|
||||
//!
|
||||
//! 提供统一的 HTTP Client 构建功能,支持代理配置
|
||||
|
||||
use reqwest::{Client, Proxy};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::model::config::TlsBackend;
|
||||
|
||||
/// 代理配置
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub struct ProxyConfig {
|
||||
/// 代理地址,支持 http/https/socks5
|
||||
pub url: String,
|
||||
/// 代理认证用户名
|
||||
pub username: Option<String>,
|
||||
/// 代理认证密码
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
/// 从 url 创建代理配置
|
||||
pub fn new(url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
url: url.into(),
|
||||
username: None,
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置认证信息
|
||||
pub fn with_auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
|
||||
self.username = Some(username.into());
|
||||
self.password = Some(password.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 HTTP Client
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `proxy` - 可选的代理配置
|
||||
/// * `timeout_secs` - 超时时间(秒)
|
||||
///
|
||||
/// # Returns
|
||||
/// 配置好的 reqwest::Client
|
||||
pub fn build_client(
|
||||
proxy: Option<&ProxyConfig>,
|
||||
timeout_secs: u64,
|
||||
tls_backend: TlsBackend,
|
||||
) -> anyhow::Result<Client> {
|
||||
let mut builder = Client::builder().timeout(Duration::from_secs(timeout_secs));
|
||||
|
||||
if tls_backend == TlsBackend::Rustls {
|
||||
builder = builder.use_rustls_tls();
|
||||
}
|
||||
|
||||
if let Some(proxy_config) = proxy {
|
||||
let mut proxy = Proxy::all(&proxy_config.url)?;
|
||||
|
||||
// 设置代理认证
|
||||
if let (Some(username), Some(password)) = (&proxy_config.username, &proxy_config.password) {
|
||||
proxy = proxy.basic_auth(username, password);
|
||||
}
|
||||
|
||||
builder = builder.proxy(proxy);
|
||||
tracing::debug!("HTTP Client 使用代理: {}", proxy_config.url);
|
||||
}
|
||||
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_proxy_config_new() {
|
||||
let config = ProxyConfig::new("http://127.0.0.1:7890");
|
||||
assert_eq!(config.url, "http://127.0.0.1:7890");
|
||||
assert!(config.username.is_none());
|
||||
assert!(config.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proxy_config_with_auth() {
|
||||
let config = ProxyConfig::new("socks5://127.0.0.1:1080").with_auth("user", "pass");
|
||||
assert_eq!(config.url, "socks5://127.0.0.1:1080");
|
||||
assert_eq!(config.username, Some("user".to_string()));
|
||||
assert_eq!(config.password, Some("pass".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_without_proxy() {
|
||||
let client = build_client(None, 30, TlsBackend::Rustls);
|
||||
assert!(client.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_client_with_proxy() {
|
||||
let config = ProxyConfig::new("http://127.0.0.1:7890");
|
||||
let client = build_client(Some(&config), 30, TlsBackend::Rustls);
|
||||
assert!(client.is_ok());
|
||||
}
|
||||
}
|
||||
559
src/image.rs
Normal file
559
src/image.rs
Normal file
@@ -0,0 +1,559 @@
|
||||
//! 图片处理模块
|
||||
//!
|
||||
//! 提供图片 token 计算和缩放功能。
|
||||
//!
|
||||
//! # Token 计算公式(Anthropic 官方)
|
||||
//! ```text
|
||||
//! tokens = (width × height) / 750
|
||||
//! ```
|
||||
//!
|
||||
//! # 缩放规则
|
||||
//! 1. 长边超过 max_long_edge 时,等比缩放
|
||||
//! 2. 总像素超过 max_pixels 时,等比缩放
|
||||
//! 3. 多图模式(图片数 >= threshold)使用独立的像素限制配置
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use image::AnimationDecoder;
|
||||
use image::codecs::gif::GifDecoder;
|
||||
use image::{DynamicImage, ImageFormat, ImageReader};
|
||||
use std::io::{BufReader, Cursor};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::model::config::CompressionConfig;
|
||||
|
||||
const GIF_MAX_OUTPUT_FRAMES: usize = 20;
|
||||
const GIF_MAX_FPS: usize = 5;
|
||||
const GIF_MIN_FRAME_DELAY: Duration = Duration::from_millis(10);
|
||||
const GIF_FRAME_OUTPUT_FORMAT: &str = "jpeg";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GifSamplingResult {
|
||||
pub frames: Vec<ImageProcessResult>,
|
||||
pub duration_ms: u64,
|
||||
pub source_frames: usize,
|
||||
pub sampling_interval_ms: u64,
|
||||
pub output_format: &'static str,
|
||||
}
|
||||
|
||||
/// 图片处理结果
|
||||
#[derive(Debug)]
|
||||
pub struct ImageProcessResult {
|
||||
/// 处理后的 base64 数据
|
||||
pub data: String,
|
||||
/// 原始尺寸 (width, height)
|
||||
pub original_size: (u32, u32),
|
||||
/// 处理后尺寸 (width, height)
|
||||
pub final_size: (u32, u32),
|
||||
/// 估算的 token 数
|
||||
pub tokens: u64,
|
||||
/// 是否进行了缩放
|
||||
pub was_resized: bool,
|
||||
/// 是否进行了重新编码(即使无需缩放)
|
||||
///
|
||||
/// 主要用于 GIF:即便尺寸已符合限制,也会重新编码为静态帧,
|
||||
/// 避免把“体积巨大但分辨率很小的动图”原样发送到上游导致请求体过大。
|
||||
pub was_reencoded: bool,
|
||||
/// 原始图片字节数(base64 解码后)
|
||||
pub original_bytes_len: usize,
|
||||
/// 处理后图片字节数(编码后、base64 前)
|
||||
pub final_bytes_len: usize,
|
||||
}
|
||||
|
||||
/// 将 GIF 抽帧并重编码为多张静态图(用于降低请求体、提升“动图内容”识别效果)
|
||||
///
|
||||
/// 采样策略(符合你给的约束):
|
||||
/// - 总帧数不超过 `GIF_MAX_OUTPUT_FRAMES`
|
||||
/// - 采样频率不超过 `GIF_MAX_FPS`(每秒最多 5 张)
|
||||
/// - 当 GIF 过长导致超出总帧数时,按“秒级上限”下调采样频率(例如 8 秒 GIF → 每秒最多 2 张)
|
||||
pub fn process_gif_frames(
|
||||
base64_data: &str,
|
||||
config: &CompressionConfig,
|
||||
image_count: usize,
|
||||
) -> Result<GifSamplingResult, String> {
|
||||
let gif_bytes = BASE64
|
||||
.decode(base64_data)
|
||||
.map_err(|e| format!("base64 解码失败: {}", e))?;
|
||||
let original_bytes_len = gif_bytes.len();
|
||||
|
||||
// Pass 1:计算时长(ms)与源帧数,用于确定采样间隔
|
||||
let (duration_ms, source_frames) = {
|
||||
let decoder = GifDecoder::new(BufReader::new(Cursor::new(&gif_bytes)))
|
||||
.map_err(|e| format!("GIF 解码失败: {}", e))?;
|
||||
let mut total = 0u64;
|
||||
let mut n = 0usize;
|
||||
for frame in decoder.into_frames() {
|
||||
let frame = frame.map_err(|e| format!("GIF 帧解码失败: {}", e))?;
|
||||
let delay = Duration::from(frame.delay()).max(GIF_MIN_FRAME_DELAY);
|
||||
total = total.saturating_add(delay.as_millis().min(u128::from(u64::MAX)) as u64);
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return Err("GIF 不包含任何帧".to_string());
|
||||
}
|
||||
(total.max(1), n)
|
||||
};
|
||||
|
||||
// 计算采样间隔:
|
||||
// - 优先按“每秒最多 N 张(N<=5)”控制(用户期望的直觉规则)
|
||||
// - 当 GIF 超长(duration_secs > max_frames)时,转为按 max_frames 均匀采样
|
||||
let duration_secs_ceil = duration_ms.div_ceil(1000).max(1) as usize;
|
||||
let fps_by_total = GIF_MAX_OUTPUT_FRAMES / duration_secs_ceil; // integer fps-per-second cap
|
||||
let fps = fps_by_total.min(GIF_MAX_FPS);
|
||||
let sampling_interval_ms = if fps > 0 {
|
||||
(1000 / fps as u64).max(1000 / GIF_MAX_FPS as u64)
|
||||
} else {
|
||||
// duration_secs_ceil > GIF_MAX_OUTPUT_FRAMES:平均 < 1 fps,改为均匀抽取 max_frames 张
|
||||
duration_ms.div_ceil(GIF_MAX_OUTPUT_FRAMES as u64).max(1)
|
||||
};
|
||||
|
||||
// 根据图片数量选择像素限制(复用现有策略)
|
||||
let max_pixels = if image_count >= config.image_multi_threshold {
|
||||
config.image_max_pixels_multi
|
||||
} else {
|
||||
config.image_max_pixels_single
|
||||
};
|
||||
|
||||
// Pass 2:按采样间隔选择帧并重编码
|
||||
let decoder = GifDecoder::new(BufReader::new(Cursor::new(&gif_bytes)))
|
||||
.map_err(|e| format!("GIF 解码失败: {}", e))?;
|
||||
|
||||
let mut frames_out = Vec::new();
|
||||
let mut elapsed_ms = 0u64; // 当前帧起始时间
|
||||
let mut next_sample_ms = 0u64;
|
||||
|
||||
for frame in decoder.into_frames() {
|
||||
if frames_out.len() >= GIF_MAX_OUTPUT_FRAMES {
|
||||
break;
|
||||
}
|
||||
|
||||
let frame = frame.map_err(|e| format!("GIF 帧解码失败: {}", e))?;
|
||||
let delay = Duration::from(frame.delay()).max(GIF_MIN_FRAME_DELAY);
|
||||
let frame_start_ms = elapsed_ms;
|
||||
|
||||
// 用帧起始时间做采样(避免同一帧被重复选中)
|
||||
if frame_start_ms >= next_sample_ms {
|
||||
let buffer = frame.into_buffer();
|
||||
let original_size = (buffer.width(), buffer.height());
|
||||
|
||||
let (target_w, target_h) = apply_scaling_rules(
|
||||
original_size.0,
|
||||
original_size.1,
|
||||
config.image_max_long_edge,
|
||||
max_pixels,
|
||||
);
|
||||
let needs_resize = target_w != original_size.0 || target_h != original_size.1;
|
||||
|
||||
let img = DynamicImage::ImageRgba8(buffer);
|
||||
let processed = if needs_resize {
|
||||
img.resize(target_w, target_h, image::imageops::FilterType::Lanczos3)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let final_size = (processed.width(), processed.height());
|
||||
let (data, final_bytes_len) = encode_image(&processed, GIF_FRAME_OUTPUT_FORMAT)?;
|
||||
|
||||
frames_out.push(ImageProcessResult {
|
||||
data,
|
||||
original_size,
|
||||
final_size,
|
||||
tokens: calculate_tokens(final_size.0, final_size.1),
|
||||
was_resized: needs_resize,
|
||||
was_reencoded: true,
|
||||
original_bytes_len,
|
||||
final_bytes_len,
|
||||
});
|
||||
|
||||
next_sample_ms = frame_start_ms.saturating_add(sampling_interval_ms);
|
||||
}
|
||||
|
||||
elapsed_ms = elapsed_ms.saturating_add(delay.as_millis().min(u128::from(u64::MAX)) as u64);
|
||||
}
|
||||
|
||||
if frames_out.is_empty() {
|
||||
return Err("GIF 抽帧结果为空".to_string());
|
||||
}
|
||||
|
||||
Ok(GifSamplingResult {
|
||||
frames: frames_out,
|
||||
duration_ms,
|
||||
source_frames,
|
||||
sampling_interval_ms,
|
||||
output_format: GIF_FRAME_OUTPUT_FORMAT,
|
||||
})
|
||||
}
|
||||
|
||||
/// 强制将任意图片重编码为指定格式(可选缩放)
|
||||
///
|
||||
/// 用于需要把输入格式(如 GIF)转换为上游更稳定支持的静态格式(如 JPEG)时。
|
||||
pub fn process_image_to_format(
|
||||
base64_data: &str,
|
||||
output_format: &str,
|
||||
config: &CompressionConfig,
|
||||
image_count: usize,
|
||||
) -> Result<ImageProcessResult, String> {
|
||||
let bytes = BASE64
|
||||
.decode(base64_data)
|
||||
.map_err(|e| format!("base64 解码失败: {}", e))?;
|
||||
let original_bytes_len = bytes.len();
|
||||
|
||||
let reader = ImageReader::new(Cursor::new(&bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| format!("图片格式识别失败: {}", e))?;
|
||||
let original_size = reader
|
||||
.into_dimensions()
|
||||
.map_err(|e| format!("读取图片尺寸失败: {}", e))?;
|
||||
|
||||
let max_pixels = if image_count >= config.image_multi_threshold {
|
||||
config.image_max_pixels_multi
|
||||
} else {
|
||||
config.image_max_pixels_single
|
||||
};
|
||||
|
||||
let (target_w, target_h) = apply_scaling_rules(
|
||||
original_size.0,
|
||||
original_size.1,
|
||||
config.image_max_long_edge,
|
||||
max_pixels,
|
||||
);
|
||||
let needs_resize = target_w != original_size.0 || target_h != original_size.1;
|
||||
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| format!("图片加载失败: {}", e))?;
|
||||
let processed = if needs_resize {
|
||||
img.resize(target_w, target_h, image::imageops::FilterType::Lanczos3)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let final_size = (processed.width(), processed.height());
|
||||
let (data, final_bytes_len) = encode_image(&processed, output_format)?;
|
||||
|
||||
Ok(ImageProcessResult {
|
||||
data,
|
||||
original_size,
|
||||
final_size,
|
||||
tokens: calculate_tokens(final_size.0, final_size.1),
|
||||
was_resized: needs_resize,
|
||||
was_reencoded: true,
|
||||
original_bytes_len,
|
||||
final_bytes_len,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 base64 数据计算图片 token(不缩放)
|
||||
///
|
||||
/// 返回 (tokens, width, height),解析失败返回 None
|
||||
pub fn estimate_image_tokens(base64_data: &str) -> Option<(u64, u32, u32)> {
|
||||
let bytes = BASE64.decode(base64_data).ok()?;
|
||||
let reader = ImageReader::new(Cursor::new(&bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?;
|
||||
let (width, height) = reader.into_dimensions().ok()?;
|
||||
|
||||
// 应用 Anthropic 缩放规则计算 token
|
||||
let (scaled_w, scaled_h) = apply_scaling_rules(width, height, 1568, 1_150_000);
|
||||
let tokens = calculate_tokens(scaled_w, scaled_h);
|
||||
|
||||
Some((tokens, width, height))
|
||||
}
|
||||
|
||||
/// 处理图片:根据配置缩放并返回处理结果
|
||||
///
|
||||
/// # 参数
|
||||
/// - `base64_data`: 原始 base64 编码的图片数据
|
||||
/// - `format`: 图片格式("jpeg", "png", "gif", "webp")
|
||||
/// - `config`: 压缩配置
|
||||
/// - `image_count`: 当前请求中的图片总数(用于判断是否启用多图模式)
|
||||
pub fn process_image(
|
||||
base64_data: &str,
|
||||
format: &str,
|
||||
config: &CompressionConfig,
|
||||
image_count: usize,
|
||||
) -> Result<ImageProcessResult, String> {
|
||||
// 解码 base64
|
||||
let bytes = BASE64
|
||||
.decode(base64_data)
|
||||
.map_err(|e| format!("base64 解码失败: {}", e))?;
|
||||
let original_bytes_len = bytes.len();
|
||||
|
||||
// 先只读取图片头获取尺寸(避免不必要的全量解码)
|
||||
let reader = ImageReader::new(Cursor::new(&bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| format!("图片格式识别失败: {}", e))?;
|
||||
let original_size = reader
|
||||
.into_dimensions()
|
||||
.map_err(|e| format!("读取图片尺寸失败: {}", e))?;
|
||||
|
||||
// 根据图片数量选择像素限制
|
||||
let max_pixels = if image_count >= config.image_multi_threshold {
|
||||
config.image_max_pixels_multi
|
||||
} else {
|
||||
config.image_max_pixels_single
|
||||
};
|
||||
|
||||
// 计算目标尺寸
|
||||
let (target_w, target_h) = apply_scaling_rules(
|
||||
original_size.0,
|
||||
original_size.1,
|
||||
config.image_max_long_edge,
|
||||
max_pixels,
|
||||
);
|
||||
|
||||
let needs_resize = target_w != original_size.0 || target_h != original_size.1;
|
||||
|
||||
// 判断是否需要重新编码:
|
||||
// 1. GIF 特殊处理:即使不需要缩放,也强制重新编码为静态帧
|
||||
// 原因:动图通常"像素不大但字节巨大",直接透传 base64 会显著放大请求体
|
||||
// 2. 文件过大:即使尺寸符合要求,如果文件大小超过阈值也需要重新编码降低质量
|
||||
// 阈值:200KB(经验值,避免小图片被过度压缩,同时拦截高质量大图)
|
||||
const MAX_IMAGE_BYTES: usize = 200_000; // 200KB
|
||||
let force_reencode_gif = format.eq_ignore_ascii_case("gif");
|
||||
let force_reencode_large = original_bytes_len > MAX_IMAGE_BYTES;
|
||||
|
||||
if force_reencode_large {
|
||||
tracing::info!(
|
||||
original_bytes = original_bytes_len,
|
||||
threshold_bytes = MAX_IMAGE_BYTES,
|
||||
width = original_size.0,
|
||||
height = original_size.1,
|
||||
format = format,
|
||||
"图片文件过大,强制重新编码以降低质量"
|
||||
);
|
||||
}
|
||||
|
||||
let should_decode_and_encode = needs_resize || force_reencode_gif || force_reencode_large;
|
||||
|
||||
// 仅在需要缩放或强制重编码时才全量解码图片
|
||||
let (output_data, final_size, final_bytes_len, was_reencoded) = if should_decode_and_encode {
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| format!("图片加载失败: {}", e))?;
|
||||
let processed = if needs_resize {
|
||||
img.resize(target_w, target_h, image::imageops::FilterType::Lanczos3)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let size = (processed.width(), processed.height());
|
||||
let (data, bytes_len) = encode_image(&processed, format)?;
|
||||
let was_reencoded = (force_reencode_gif || force_reencode_large) && !needs_resize;
|
||||
|
||||
if force_reencode_large && !needs_resize {
|
||||
tracing::info!(
|
||||
original_bytes = original_bytes_len,
|
||||
final_bytes = bytes_len,
|
||||
compression_ratio = format!(
|
||||
"{:.1}%",
|
||||
(1.0 - bytes_len as f64 / original_bytes_len as f64) * 100.0
|
||||
),
|
||||
"大文件重新编码完成"
|
||||
);
|
||||
}
|
||||
|
||||
(data, size, bytes_len, was_reencoded)
|
||||
} else {
|
||||
(
|
||||
base64_data.to_string(),
|
||||
original_size,
|
||||
original_bytes_len,
|
||||
false,
|
||||
)
|
||||
};
|
||||
|
||||
let tokens = calculate_tokens(final_size.0, final_size.1);
|
||||
|
||||
Ok(ImageProcessResult {
|
||||
data: output_data,
|
||||
original_size,
|
||||
final_size,
|
||||
tokens,
|
||||
was_resized: needs_resize,
|
||||
was_reencoded,
|
||||
original_bytes_len,
|
||||
final_bytes_len,
|
||||
})
|
||||
}
|
||||
|
||||
/// 应用 Anthropic 缩放规则
|
||||
///
|
||||
/// 1. 长边不超过 max_long_edge
|
||||
/// 2. 总像素不超过 max_pixels
|
||||
fn apply_scaling_rules(width: u32, height: u32, max_long_edge: u32, max_pixels: u32) -> (u32, u32) {
|
||||
let mut w = width as f64;
|
||||
let mut h = height as f64;
|
||||
|
||||
// 规则 1: 长边限制
|
||||
let long_edge = w.max(h);
|
||||
if long_edge > max_long_edge as f64 {
|
||||
let scale = max_long_edge as f64 / long_edge;
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
}
|
||||
|
||||
// 规则 2: 总像素限制
|
||||
let pixels = w * h;
|
||||
if pixels > max_pixels as f64 {
|
||||
let scale = (max_pixels as f64 / pixels).sqrt();
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
}
|
||||
|
||||
(w.floor().max(1.0) as u32, h.floor().max(1.0) as u32)
|
||||
}
|
||||
|
||||
/// 计算 token 数
|
||||
#[inline]
|
||||
fn calculate_tokens(width: u32, height: u32) -> u64 {
|
||||
((width as u64 * height as u64) + 375) / 750 // 四舍五入
|
||||
}
|
||||
|
||||
/// 将图片编码为 base64
|
||||
fn encode_image(img: &DynamicImage, format: &str) -> Result<(String, usize), String> {
|
||||
let mut buffer = Cursor::new(Vec::new());
|
||||
|
||||
let image_format = match format {
|
||||
"jpeg" | "jpg" => ImageFormat::Jpeg,
|
||||
"png" => ImageFormat::Png,
|
||||
"gif" => ImageFormat::Gif,
|
||||
"webp" => ImageFormat::WebP,
|
||||
_ => return Err(format!("不支持的图片格式: {}", format)),
|
||||
};
|
||||
|
||||
img.write_to(&mut buffer, image_format)
|
||||
.map_err(|e| format!("图片编码失败: {}", e))?;
|
||||
|
||||
let encoded = buffer.into_inner();
|
||||
let bytes_len = encoded.len();
|
||||
Ok((BASE64.encode(encoded), bytes_len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scaling_rules() {
|
||||
// 测试长边限制
|
||||
assert_eq!(
|
||||
apply_scaling_rules(2000, 1000, 1568, 10_000_000),
|
||||
(1568, 784)
|
||||
);
|
||||
|
||||
// 测试像素限制
|
||||
assert_eq!(
|
||||
apply_scaling_rules(1200, 1200, 1568, 1_000_000),
|
||||
(1000, 1000)
|
||||
);
|
||||
|
||||
// 测试无需缩放
|
||||
assert_eq!(apply_scaling_rules(800, 600, 1568, 1_150_000), (800, 600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_tokens() {
|
||||
assert_eq!(calculate_tokens(1092, 1092), 1590); // 1:1 标准
|
||||
assert_eq!(calculate_tokens(200, 200), 53); // 小图
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gif_is_reencoded_even_without_resize() {
|
||||
use image::codecs::gif::{GifEncoder, Repeat};
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
|
||||
// 构造一个多帧 GIF(像素小但包含多帧),用于验证“强制重编码为静态帧”的行为。
|
||||
let mut frames = Vec::new();
|
||||
for i in 0..10u8 {
|
||||
let mut img = RgbaImage::new(32, 32);
|
||||
for p in img.pixels_mut() {
|
||||
*p = Rgba([i, 255u8.saturating_sub(i), 0, 255]);
|
||||
}
|
||||
frames.push(Frame::from_parts(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
Delay::from_numer_denom_ms(10, 1),
|
||||
));
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = GifEncoder::new(&mut buf);
|
||||
encoder.set_repeat(Repeat::Infinite).unwrap();
|
||||
encoder.encode_frames(frames).unwrap();
|
||||
}
|
||||
|
||||
let base64_data = BASE64.encode(&buf);
|
||||
let config = CompressionConfig::default();
|
||||
let result = process_image(&base64_data, "gif", &config, 1).unwrap();
|
||||
|
||||
assert!(!result.was_resized);
|
||||
assert!(result.was_reencoded);
|
||||
assert_eq!(result.original_size, result.final_size);
|
||||
assert!(result.final_bytes_len < result.original_bytes_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_gif_frames_sampling_8s_caps_to_2fps() {
|
||||
use image::codecs::gif::{GifEncoder, Repeat};
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
|
||||
// 8 秒 GIF:按 max 20 帧限制,每秒最多 2 帧(20/8=2)
|
||||
let frame_delay = Delay::from_numer_denom_ms(100, 1); // 0.1s
|
||||
let mut frames = Vec::new();
|
||||
for i in 0..80u8 {
|
||||
let mut img = RgbaImage::new(64, 64);
|
||||
for p in img.pixels_mut() {
|
||||
*p = Rgba([i, 0, 0, 255]);
|
||||
}
|
||||
frames.push(Frame::from_parts(img, 0, 0, frame_delay));
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = GifEncoder::new(&mut buf);
|
||||
encoder.set_repeat(Repeat::Infinite).unwrap();
|
||||
encoder.encode_frames(frames).unwrap();
|
||||
}
|
||||
|
||||
let base64_data = BASE64.encode(&buf);
|
||||
let config = CompressionConfig::default();
|
||||
let res = process_gif_frames(&base64_data, &config, 1).unwrap();
|
||||
|
||||
assert_eq!(res.duration_ms, 8000);
|
||||
assert_eq!(res.sampling_interval_ms, 500);
|
||||
assert_eq!(res.frames.len(), 16);
|
||||
assert!(res.frames.len() <= GIF_MAX_OUTPUT_FRAMES);
|
||||
assert_eq!(res.output_format, GIF_FRAME_OUTPUT_FORMAT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_gif_frames_sampling_4s_hits_5fps_and_20_frames() {
|
||||
use image::codecs::gif::{GifEncoder, Repeat};
|
||||
use image::{Delay, Frame, Rgba, RgbaImage};
|
||||
|
||||
// 4 秒 GIF:每秒最多 5 帧(20/4=5),应能采满 20 帧
|
||||
let frame_delay = Delay::from_numer_denom_ms(100, 1); // 0.1s
|
||||
let mut frames = Vec::new();
|
||||
for i in 0..40u8 {
|
||||
let mut img = RgbaImage::new(64, 64);
|
||||
for p in img.pixels_mut() {
|
||||
*p = Rgba([0, i, 0, 255]);
|
||||
}
|
||||
frames.push(Frame::from_parts(img, 0, 0, frame_delay));
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = GifEncoder::new(&mut buf);
|
||||
encoder.set_repeat(Repeat::Infinite).unwrap();
|
||||
encoder.encode_frames(frames).unwrap();
|
||||
}
|
||||
|
||||
let base64_data = BASE64.encode(&buf);
|
||||
let config = CompressionConfig::default();
|
||||
let res = process_gif_frames(&base64_data, &config, 1).unwrap();
|
||||
|
||||
assert_eq!(res.duration_ms, 4000);
|
||||
assert_eq!(res.sampling_interval_ms, 200);
|
||||
assert_eq!(res.frames.len(), 20);
|
||||
assert!(res.frames.len() <= GIF_MAX_OUTPUT_FRAMES);
|
||||
assert_eq!(res.output_format, GIF_FRAME_OUTPUT_FORMAT);
|
||||
}
|
||||
}
|
||||
86
src/kiro/affinity.rs
Normal file
86
src/kiro/affinity.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! 用户亲和性管理模块
|
||||
//!
|
||||
//! 记录 user_id 与 credential_id 的绑定关系,
|
||||
//! 使连续对话尽量使用同一凭据
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 亲和性条目
|
||||
struct AffinityEntry {
|
||||
credential_id: u64,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
/// 用户亲和性管理器
|
||||
pub struct UserAffinityManager {
|
||||
affinity: Mutex<HashMap<String, AffinityEntry>>,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl Default for UserAffinityManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UserAffinityManager {
|
||||
/// 创建新的亲和性管理器(默认 TTL 30 分钟)
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
affinity: Mutex::new(HashMap::new()),
|
||||
ttl: Duration::from_secs(30 * 60),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取用户绑定的凭据 ID(惰性清理过期条目)
|
||||
pub fn get(&self, user_id: &str) -> Option<u64> {
|
||||
let mut map = self.affinity.lock();
|
||||
if let Some(entry) = map.get(user_id) {
|
||||
if entry.last_used.elapsed() < self.ttl {
|
||||
tracing::debug!(user_id = %user_id, credential_id = %entry.credential_id, "亲和性命中");
|
||||
return Some(entry.credential_id);
|
||||
}
|
||||
// 过期则删除
|
||||
tracing::debug!(user_id = %user_id, credential_id = %entry.credential_id, "亲和性过期,已清除");
|
||||
map.remove(user_id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 设置用户与凭据的绑定
|
||||
pub fn set(&self, user_id: &str, credential_id: u64) {
|
||||
tracing::debug!(user_id = %user_id, credential_id = %credential_id, "建立亲和性绑定");
|
||||
let mut map = self.affinity.lock();
|
||||
map.insert(
|
||||
user_id.to_string(),
|
||||
AffinityEntry {
|
||||
credential_id,
|
||||
last_used: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新最后使用时间(续期)
|
||||
pub fn touch(&self, user_id: &str) {
|
||||
let mut map = self.affinity.lock();
|
||||
if let Some(entry) = map.get_mut(user_id) {
|
||||
entry.last_used = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除指定凭据的所有绑定(凭据被禁用时调用)
|
||||
pub fn remove_by_credential(&self, credential_id: u64) {
|
||||
let mut map = self.affinity.lock();
|
||||
map.retain(|_, entry| entry.credential_id != credential_id);
|
||||
}
|
||||
|
||||
/// 清理过期条目
|
||||
#[allow(dead_code)]
|
||||
pub fn cleanup(&self) {
|
||||
let mut map = self.affinity.lock();
|
||||
let ttl = self.ttl;
|
||||
map.retain(|_, entry| entry.last_used.elapsed() < ttl);
|
||||
}
|
||||
}
|
||||
346
src/kiro/background_refresh.rs
Normal file
346
src/kiro/background_refresh.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
//! 后台 Token 刷新模块
|
||||
#![allow(dead_code)]
|
||||
//!
|
||||
//! 独立后台任务定期检查并刷新即将过期的 Token,
|
||||
//! 避免请求时的刷新延迟。
|
||||
//! 参考 CLIProxyAPIPlus 的实现。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time::interval;
|
||||
|
||||
/// 默认检查间隔(秒)
|
||||
const DEFAULT_CHECK_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
/// 默认批处理大小
|
||||
const DEFAULT_BATCH_SIZE: usize = 50;
|
||||
|
||||
/// 默认并发数
|
||||
const DEFAULT_CONCURRENCY: usize = 10;
|
||||
|
||||
/// 默认提前刷新时间(分钟)
|
||||
/// Token 在过期前多少分钟开始刷新
|
||||
const DEFAULT_REFRESH_BEFORE_EXPIRY_MINS: i64 = 15;
|
||||
|
||||
/// 后台刷新配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackgroundRefreshConfig {
|
||||
/// 检查间隔(秒),必须 > 0
|
||||
pub check_interval_secs: u64,
|
||||
|
||||
/// 批处理大小,必须 > 0
|
||||
pub batch_size: usize,
|
||||
|
||||
/// 并发数,必须 > 0
|
||||
pub concurrency: usize,
|
||||
|
||||
/// 提前刷新时间(分钟)
|
||||
pub refresh_before_expiry_mins: i64,
|
||||
}
|
||||
|
||||
impl BackgroundRefreshConfig {
|
||||
/// 校验配置有效性
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` - 配置有效
|
||||
/// - `Err(String)` - 配置无效,返回错误描述
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.check_interval_secs == 0 {
|
||||
return Err("check_interval_secs 必须大于 0".to_string());
|
||||
}
|
||||
if self.batch_size == 0 {
|
||||
return Err("batch_size 必须大于 0".to_string());
|
||||
}
|
||||
if self.concurrency == 0 {
|
||||
return Err("concurrency 必须大于 0".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BackgroundRefreshConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
|
||||
batch_size: DEFAULT_BATCH_SIZE,
|
||||
concurrency: DEFAULT_CONCURRENCY,
|
||||
refresh_before_expiry_mins: DEFAULT_REFRESH_BEFORE_EXPIRY_MINS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台刷新器
|
||||
///
|
||||
/// 管理后台 Token 刷新任务
|
||||
pub struct BackgroundRefresher {
|
||||
config: BackgroundRefreshConfig,
|
||||
running: Arc<AtomicBool>,
|
||||
shutdown_notify: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl BackgroundRefresher {
|
||||
/// 创建新的后台刷新器
|
||||
pub fn new(config: BackgroundRefreshConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
shutdown_notify: Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用默认配置创建后台刷新器
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(BackgroundRefreshConfig::default())
|
||||
}
|
||||
|
||||
/// 启动后台刷新任务
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `refresh_fn` - 刷新函数,接收凭据 ID,返回是否成功
|
||||
/// * `get_expiring_ids_fn` - 获取即将过期的凭据 ID 列表
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` - 启动成功
|
||||
/// - `Err(String)` - 配置无效或已在运行
|
||||
pub fn start<F, G>(&self, refresh_fn: F, get_expiring_ids_fn: G) -> Result<(), String>
|
||||
where
|
||||
F: Fn(u64) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
G: Fn(i64) -> Vec<u64> + Send + Sync + 'static,
|
||||
{
|
||||
// P1 修复:启动前校验配置,避免 panic/hang
|
||||
if let Err(e) = self.config.validate() {
|
||||
tracing::error!("后台刷新器配置无效: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
if self.running.swap(true, Ordering::SeqCst) {
|
||||
tracing::warn!("后台刷新器已在运行");
|
||||
return Err("后台刷新器已在运行".to_string());
|
||||
}
|
||||
|
||||
let config = self.config.clone();
|
||||
let running = Arc::clone(&self.running);
|
||||
let shutdown_notify = Arc::clone(&self.shutdown_notify);
|
||||
let refresh_fn = Arc::new(refresh_fn);
|
||||
|
||||
tokio::spawn(async move {
|
||||
tracing::info!(
|
||||
interval_secs = %config.check_interval_secs,
|
||||
batch_size = %config.batch_size,
|
||||
concurrency = %config.concurrency,
|
||||
"后台 Token 刷新器已启动"
|
||||
);
|
||||
|
||||
let mut check_interval = interval(Duration::from_secs(config.check_interval_secs));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = check_interval.tick() => {
|
||||
if !running.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 获取即将过期的凭据
|
||||
let expiring_ids = get_expiring_ids_fn(config.refresh_before_expiry_mins);
|
||||
|
||||
if expiring_ids.is_empty() {
|
||||
tracing::debug!("没有需要刷新的 Token");
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("发现 {} 个即将过期的 Token,开始刷新", expiring_ids.len());
|
||||
|
||||
// 批量刷新
|
||||
let mut success_count = 0;
|
||||
let mut fail_count = 0;
|
||||
|
||||
for chunk in expiring_ids.chunks(config.batch_size) {
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(config.concurrency));
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for &id in chunk {
|
||||
let permit = semaphore.clone().acquire_owned().await;
|
||||
let refresh_fn = Arc::clone(&refresh_fn);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
refresh_fn(id).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(true) => success_count += 1,
|
||||
Ok(false) => fail_count += 1,
|
||||
Err(e) => {
|
||||
tracing::warn!("刷新任务 panic: {}", e);
|
||||
fail_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
success = %success_count,
|
||||
failed = %fail_count,
|
||||
"后台 Token 刷新完成"
|
||||
);
|
||||
}
|
||||
_ = shutdown_notify.notified() => {
|
||||
tracing::info!("后台 Token 刷新器收到关闭信号");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
running.store(false, Ordering::SeqCst);
|
||||
tracing::info!("后台 Token 刷新器已停止");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止后台刷新任务
|
||||
pub fn stop(&self) {
|
||||
if self.running.load(Ordering::SeqCst) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
self.shutdown_notify.notify_one();
|
||||
tracing::info!("已发送后台刷新器停止信号");
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在运行
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 获取配置
|
||||
pub fn config(&self) -> &BackgroundRefreshConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BackgroundRefresher {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshResult {
|
||||
/// 凭据 ID
|
||||
pub credential_id: u64,
|
||||
|
||||
/// 是否成功
|
||||
pub success: bool,
|
||||
|
||||
/// 是否使用了降级方案
|
||||
pub used_fallback: bool,
|
||||
|
||||
/// 错误信息(如果失败)
|
||||
pub error: Option<String>,
|
||||
|
||||
/// 新的过期时间(如果成功)
|
||||
pub new_expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl RefreshResult {
|
||||
/// 创建成功结果
|
||||
pub fn success(credential_id: u64, new_expires_at: String) -> Self {
|
||||
Self {
|
||||
credential_id,
|
||||
success: true,
|
||||
used_fallback: false,
|
||||
error: None,
|
||||
new_expires_at: Some(new_expires_at),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建失败结果
|
||||
pub fn failure(credential_id: u64, error: String) -> Self {
|
||||
Self {
|
||||
credential_id,
|
||||
success: false,
|
||||
used_fallback: false,
|
||||
error: Some(error),
|
||||
new_expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建降级结果(刷新失败但使用现有 Token)
|
||||
pub fn fallback(credential_id: u64, existing_expires_at: String) -> Self {
|
||||
Self {
|
||||
credential_id,
|
||||
success: true,
|
||||
used_fallback: true,
|
||||
error: None,
|
||||
new_expires_at: Some(existing_expires_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_background_refresh_config_default() {
|
||||
let config = BackgroundRefreshConfig::default();
|
||||
assert_eq!(config.check_interval_secs, 60);
|
||||
assert_eq!(config.batch_size, 50);
|
||||
assert_eq!(config.concurrency, 10);
|
||||
assert_eq!(config.refresh_before_expiry_mins, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_refresher_new() {
|
||||
let refresher = BackgroundRefresher::with_defaults();
|
||||
assert!(!refresher.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_result_success() {
|
||||
let result = RefreshResult::success(1, "2025-01-01T00:00:00Z".to_string());
|
||||
assert!(result.success);
|
||||
assert!(!result.used_fallback);
|
||||
assert!(result.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_result_failure() {
|
||||
let result = RefreshResult::failure(1, "Token expired".to_string());
|
||||
assert!(!result.success);
|
||||
assert!(result.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_result_fallback() {
|
||||
let result = RefreshResult::fallback(1, "2025-01-01T00:00:00Z".to_string());
|
||||
assert!(result.success);
|
||||
assert!(result.used_fallback);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_background_refresher_stop() {
|
||||
let refresher = BackgroundRefresher::with_defaults();
|
||||
|
||||
// 启动一个空的刷新任务
|
||||
let _ = refresher.start(|_id| Box::pin(async { true }), |_mins| vec![]);
|
||||
|
||||
// 等待启动
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(refresher.is_running());
|
||||
|
||||
// 停止
|
||||
refresher.stop();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(!refresher.is_running());
|
||||
}
|
||||
}
|
||||
373
src/kiro/cooldown.rs
Normal file
373
src/kiro/cooldown.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! 冷却管理模块
|
||||
#![allow(dead_code)]
|
||||
//!
|
||||
//! 分类管理不同原因的冷却状态,支持差异化冷却时长和自动清理。
|
||||
//! 参考 CLIProxyAPIPlus 的实现。
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 冷却原因
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CooldownReason {
|
||||
/// 429 速率限制
|
||||
RateLimitExceeded,
|
||||
|
||||
/// 账户暂停
|
||||
AccountSuspended,
|
||||
|
||||
/// 配额耗尽
|
||||
QuotaExhausted,
|
||||
|
||||
/// Token 刷新失败
|
||||
TokenRefreshFailed,
|
||||
|
||||
/// 认证失败
|
||||
AuthenticationFailed,
|
||||
|
||||
/// 服务器错误
|
||||
ServerError,
|
||||
|
||||
/// 模型暂时不可用
|
||||
ModelUnavailable,
|
||||
}
|
||||
|
||||
impl CooldownReason {
|
||||
/// 获取默认冷却时长
|
||||
pub fn default_duration(&self) -> Duration {
|
||||
match self {
|
||||
// 短冷却(1-5 分钟)
|
||||
CooldownReason::RateLimitExceeded => Duration::from_secs(60),
|
||||
CooldownReason::TokenRefreshFailed => Duration::from_secs(60),
|
||||
CooldownReason::ServerError => Duration::from_secs(120),
|
||||
CooldownReason::ModelUnavailable => Duration::from_secs(300),
|
||||
|
||||
// 长冷却(1-24 小时)
|
||||
CooldownReason::AuthenticationFailed => Duration::from_secs(3600),
|
||||
CooldownReason::AccountSuspended => Duration::from_secs(86400),
|
||||
CooldownReason::QuotaExhausted => Duration::from_secs(86400),
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否可以自动恢复
|
||||
pub fn is_auto_recoverable(&self) -> bool {
|
||||
match self {
|
||||
CooldownReason::RateLimitExceeded => true,
|
||||
CooldownReason::TokenRefreshFailed => true,
|
||||
CooldownReason::ServerError => true,
|
||||
CooldownReason::ModelUnavailable => true,
|
||||
CooldownReason::AuthenticationFailed => false,
|
||||
CooldownReason::AccountSuspended => false,
|
||||
CooldownReason::QuotaExhausted => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取原因描述
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
CooldownReason::RateLimitExceeded => "速率限制",
|
||||
CooldownReason::AccountSuspended => "账户暂停",
|
||||
CooldownReason::QuotaExhausted => "配额耗尽",
|
||||
CooldownReason::TokenRefreshFailed => "Token 刷新失败",
|
||||
CooldownReason::AuthenticationFailed => "认证失败",
|
||||
CooldownReason::ServerError => "服务器错误",
|
||||
CooldownReason::ModelUnavailable => "模型暂时不可用",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 冷却条目
|
||||
#[derive(Debug, Clone)]
|
||||
struct CooldownEntry {
|
||||
/// 冷却原因
|
||||
reason: CooldownReason,
|
||||
|
||||
/// 冷却开始时间
|
||||
started_at: Instant,
|
||||
|
||||
/// 冷却结束时间
|
||||
expires_at: Instant,
|
||||
|
||||
/// 连续触发次数(用于递增冷却时长)
|
||||
trigger_count: u32,
|
||||
}
|
||||
|
||||
/// 冷却管理器
|
||||
///
|
||||
/// 管理所有凭据的冷却状态
|
||||
pub struct CooldownManager {
|
||||
/// 凭据冷却状态
|
||||
entries: Mutex<HashMap<u64, CooldownEntry>>,
|
||||
|
||||
/// 最大短冷却时长(秒)
|
||||
max_short_cooldown_secs: u64,
|
||||
|
||||
/// 长冷却时长(秒)
|
||||
long_cooldown_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for CooldownManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CooldownManager {
|
||||
/// 创建新的冷却管理器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
max_short_cooldown_secs: 300, // 5 分钟
|
||||
long_cooldown_secs: 86400, // 24 小时
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用自定义配置创建冷却管理器
|
||||
#[allow(dead_code)]
|
||||
pub fn with_config(max_short_cooldown_secs: u64, long_cooldown_secs: u64) -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
max_short_cooldown_secs,
|
||||
long_cooldown_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置凭据冷却
|
||||
///
|
||||
/// 返回实际的冷却时长
|
||||
pub fn set_cooldown(&self, credential_id: u64, reason: CooldownReason) -> Duration {
|
||||
self.set_cooldown_with_duration(credential_id, reason, None)
|
||||
}
|
||||
|
||||
/// 设置凭据冷却(自定义时长)
|
||||
pub fn set_cooldown_with_duration(
|
||||
&self,
|
||||
credential_id: u64,
|
||||
reason: CooldownReason,
|
||||
custom_duration: Option<Duration>,
|
||||
) -> Duration {
|
||||
let mut entries = self.entries.lock();
|
||||
let now = Instant::now();
|
||||
|
||||
// 获取或创建条目
|
||||
let entry = entries
|
||||
.entry(credential_id)
|
||||
.or_insert_with(|| CooldownEntry {
|
||||
reason,
|
||||
started_at: now,
|
||||
expires_at: now,
|
||||
trigger_count: 0,
|
||||
});
|
||||
|
||||
// 更新触发次数
|
||||
if entry.reason == reason {
|
||||
entry.trigger_count += 1;
|
||||
} else {
|
||||
entry.reason = reason;
|
||||
entry.trigger_count = 1;
|
||||
}
|
||||
|
||||
// 计算冷却时长
|
||||
let duration = custom_duration
|
||||
.unwrap_or_else(|| self.calculate_cooldown_duration(reason, entry.trigger_count));
|
||||
|
||||
entry.started_at = now;
|
||||
entry.expires_at = now + duration;
|
||||
|
||||
tracing::info!(
|
||||
credential_id = %credential_id,
|
||||
reason = %reason.description(),
|
||||
duration_secs = %duration.as_secs(),
|
||||
trigger_count = %entry.trigger_count,
|
||||
"凭据进入冷却"
|
||||
);
|
||||
|
||||
duration
|
||||
}
|
||||
|
||||
/// 检查凭据是否在冷却中
|
||||
///
|
||||
/// 返回 `None` 表示不在冷却中,`Some((reason, remaining))` 表示冷却原因和剩余时间
|
||||
pub fn check_cooldown(&self, credential_id: u64) -> Option<(CooldownReason, Duration)> {
|
||||
let entries = self.entries.lock();
|
||||
let now = Instant::now();
|
||||
|
||||
entries.get(&credential_id).and_then(|entry| {
|
||||
if now < entry.expires_at {
|
||||
Some((
|
||||
entry.reason,
|
||||
entry.expires_at.saturating_duration_since(now),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 检查凭据是否可用(不在冷却中或冷却已过期)
|
||||
pub fn is_available(&self, credential_id: u64) -> bool {
|
||||
self.check_cooldown(credential_id).is_none()
|
||||
}
|
||||
|
||||
/// 清除凭据冷却
|
||||
pub fn clear_cooldown(&self, credential_id: u64) -> bool {
|
||||
let mut entries = self.entries.lock();
|
||||
entries.remove(&credential_id).is_some()
|
||||
}
|
||||
|
||||
/// 清除所有已过期的冷却
|
||||
pub fn cleanup_expired(&self) -> usize {
|
||||
let mut entries = self.entries.lock();
|
||||
let now = Instant::now();
|
||||
let before_count = entries.len();
|
||||
|
||||
entries.retain(|_, entry| now < entry.expires_at);
|
||||
|
||||
let removed = before_count - entries.len();
|
||||
if removed > 0 {
|
||||
tracing::debug!("清理了 {} 个过期冷却条目", removed);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// 获取所有冷却中的凭据
|
||||
pub fn get_all_cooldowns(&self) -> Vec<CooldownInfo> {
|
||||
let entries = self.entries.lock();
|
||||
let now = Instant::now();
|
||||
|
||||
entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| now < entry.expires_at)
|
||||
.map(|(&id, entry)| CooldownInfo {
|
||||
credential_id: id,
|
||||
reason: entry.reason,
|
||||
started_at_ms: entry.started_at.elapsed().as_millis() as u64,
|
||||
remaining_ms: entry.expires_at.saturating_duration_since(now).as_millis() as u64,
|
||||
trigger_count: entry.trigger_count,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 计算冷却时长
|
||||
fn calculate_cooldown_duration(&self, reason: CooldownReason, trigger_count: u32) -> Duration {
|
||||
let base = reason.default_duration();
|
||||
|
||||
if reason.is_auto_recoverable() {
|
||||
// 可自动恢复的原因:递增冷却时长,但不超过最大值
|
||||
let multiplier = 1.5_f64.powi((trigger_count.saturating_sub(1)) as i32);
|
||||
let duration_secs = (base.as_secs() as f64 * multiplier) as u64;
|
||||
let capped_secs = duration_secs.min(self.max_short_cooldown_secs);
|
||||
Duration::from_secs(capped_secs)
|
||||
} else {
|
||||
// 不可自动恢复的原因:使用长冷却时长
|
||||
Duration::from_secs(self.long_cooldown_secs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 冷却信息(公开 API)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CooldownInfo {
|
||||
/// 凭据 ID
|
||||
pub credential_id: u64,
|
||||
|
||||
/// 冷却原因
|
||||
pub reason: CooldownReason,
|
||||
|
||||
/// 冷却开始时间(毫秒前)
|
||||
pub started_at_ms: u64,
|
||||
|
||||
/// 剩余冷却时间(毫秒)
|
||||
pub remaining_ms: u64,
|
||||
|
||||
/// 连续触发次数
|
||||
pub trigger_count: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cooldown_manager_new() {
|
||||
let manager = CooldownManager::new();
|
||||
assert!(manager.is_available(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cooldown_set_and_check() {
|
||||
let manager = CooldownManager::new();
|
||||
|
||||
let duration = manager.set_cooldown(1, CooldownReason::RateLimitExceeded);
|
||||
assert!(duration.as_secs() >= 60);
|
||||
|
||||
let (reason, remaining) = manager.check_cooldown(1).unwrap();
|
||||
assert_eq!(reason, CooldownReason::RateLimitExceeded);
|
||||
assert!(remaining.as_secs() > 0);
|
||||
|
||||
assert!(!manager.is_available(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cooldown_clear() {
|
||||
let manager = CooldownManager::new();
|
||||
|
||||
manager.set_cooldown(1, CooldownReason::ServerError);
|
||||
assert!(!manager.is_available(1));
|
||||
|
||||
assert!(manager.clear_cooldown(1));
|
||||
assert!(manager.is_available(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cooldown_incremental() {
|
||||
let manager = CooldownManager::new();
|
||||
|
||||
// 第一次冷却
|
||||
let d1 = manager.set_cooldown(1, CooldownReason::RateLimitExceeded);
|
||||
|
||||
// 清除后再次触发,应该有更长的冷却
|
||||
manager.clear_cooldown(1);
|
||||
let d2 = manager.set_cooldown(1, CooldownReason::RateLimitExceeded);
|
||||
|
||||
// 由于触发次数增加,第二次冷却应该更长
|
||||
assert!(d2 >= d1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cooldown_reason_auto_recoverable() {
|
||||
assert!(CooldownReason::RateLimitExceeded.is_auto_recoverable());
|
||||
assert!(CooldownReason::ServerError.is_auto_recoverable());
|
||||
assert!(!CooldownReason::AccountSuspended.is_auto_recoverable());
|
||||
assert!(!CooldownReason::QuotaExhausted.is_auto_recoverable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cooldown_custom_duration() {
|
||||
let manager = CooldownManager::new();
|
||||
|
||||
let custom = Duration::from_secs(10);
|
||||
let duration =
|
||||
manager.set_cooldown_with_duration(1, CooldownReason::ServerError, Some(custom));
|
||||
|
||||
assert_eq!(duration, custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cooldown_get_all() {
|
||||
let manager = CooldownManager::new();
|
||||
|
||||
manager.set_cooldown(1, CooldownReason::RateLimitExceeded);
|
||||
manager.set_cooldown(2, CooldownReason::ServerError);
|
||||
|
||||
let cooldowns = manager.get_all_cooldowns();
|
||||
assert_eq!(cooldowns.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cooldown_reason_description() {
|
||||
assert_eq!(CooldownReason::RateLimitExceeded.description(), "速率限制");
|
||||
assert_eq!(CooldownReason::AccountSuspended.description(), "账户暂停");
|
||||
}
|
||||
}
|
||||
301
src/kiro/fingerprint.rs
Normal file
301
src/kiro/fingerprint.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
//! 多维度设备指纹系统
|
||||
#![allow(dead_code)]
|
||||
//!
|
||||
//! 模拟真实 Kiro IDE 客户端的完整环境特征,降低被检测风险。
|
||||
//! 参考 CLIProxyAPIPlus 的实现。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// SDK 版本范围
|
||||
const SDK_VERSIONS: &[&str] = &["1.0.20", "1.0.22", "1.0.24", "1.0.25", "1.0.27"];
|
||||
|
||||
/// Kiro IDE 版本范围
|
||||
const KIRO_VERSIONS: &[&str] = &["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0", "0.8.0"];
|
||||
|
||||
/// Node.js 版本范围
|
||||
const NODE_VERSIONS: &[&str] = &["18.20.4", "20.18.0", "22.11.0", "22.21.1"];
|
||||
|
||||
/// 操作系统类型
|
||||
const OS_TYPES: &[&str] = &["darwin", "win32", "linux"];
|
||||
|
||||
/// 操作系统版本(按类型分组)
|
||||
const DARWIN_VERSIONS: &[&str] = &["24.0.0", "24.1.0", "24.2.0", "24.4.0", "24.6.0"];
|
||||
const WIN32_VERSIONS: &[&str] = &["10.0.19045", "10.0.22621", "10.0.22631"];
|
||||
const LINUX_VERSIONS: &[&str] = &["6.5.0", "6.8.0", "6.11.0"];
|
||||
|
||||
/// 语言偏好
|
||||
const ACCEPT_LANGUAGES: &[&str] = &[
|
||||
"en-US,en;q=0.9",
|
||||
"en-GB,en;q=0.9",
|
||||
"zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"ja-JP,ja;q=0.9,en;q=0.8",
|
||||
"de-DE,de;q=0.9,en;q=0.8",
|
||||
"fr-FR,fr;q=0.9,en;q=0.8",
|
||||
];
|
||||
|
||||
/// 屏幕分辨率
|
||||
const SCREEN_RESOLUTIONS: &[&str] = &[
|
||||
"1920x1080",
|
||||
"2560x1440",
|
||||
"3840x2160",
|
||||
"1440x900",
|
||||
"2560x1600",
|
||||
"3024x1964",
|
||||
];
|
||||
|
||||
/// 颜色深度
|
||||
const COLOR_DEPTHS: &[u8] = &[24, 30, 32];
|
||||
|
||||
/// CPU 核心数范围
|
||||
const HARDWARE_CONCURRENCY_RANGE: (u8, u8) = (4, 32);
|
||||
|
||||
/// 时区偏移范围(分钟)
|
||||
const TIMEZONE_OFFSET_RANGE: (i16, i16) = (-720, 720);
|
||||
|
||||
/// 设备指纹
|
||||
///
|
||||
/// 包含模拟真实 Kiro IDE 客户端的完整环境特征
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Fingerprint {
|
||||
/// AWS SDK 版本
|
||||
pub sdk_version: String,
|
||||
|
||||
/// 操作系统类型
|
||||
pub os_type: String,
|
||||
|
||||
/// 操作系统版本
|
||||
pub os_version: String,
|
||||
|
||||
/// Node.js 版本
|
||||
pub node_version: String,
|
||||
|
||||
/// Kiro IDE 版本
|
||||
pub kiro_version: String,
|
||||
|
||||
/// Kiro IDE 哈希(模拟)
|
||||
pub kiro_hash: String,
|
||||
|
||||
/// 语言偏好
|
||||
pub accept_language: String,
|
||||
|
||||
/// 屏幕分辨率
|
||||
pub screen_resolution: String,
|
||||
|
||||
/// 颜色深度
|
||||
pub color_depth: u8,
|
||||
|
||||
/// CPU 核心数
|
||||
pub hardware_concurrency: u8,
|
||||
|
||||
/// 时区偏移(分钟)
|
||||
pub timezone_offset: i16,
|
||||
|
||||
/// Machine ID(64 字符十六进制)
|
||||
pub machine_id: String,
|
||||
}
|
||||
|
||||
impl Fingerprint {
|
||||
/// 基于种子生成确定性指纹
|
||||
///
|
||||
/// 使用种子确保同一凭据始终生成相同的指纹,
|
||||
/// 避免每次请求都生成不同的指纹导致被检测。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `seed` - 种子字符串(通常使用 refresh_token 或 machine_id)
|
||||
pub fn generate_from_seed(seed: &str) -> Self {
|
||||
// 使用种子生成确定性随机数
|
||||
let hash = sha256_bytes(seed);
|
||||
|
||||
// 从哈希中提取各个字段的索引
|
||||
let sdk_idx = hash[0] as usize % SDK_VERSIONS.len();
|
||||
let kiro_idx = hash[1] as usize % KIRO_VERSIONS.len();
|
||||
let node_idx = hash[2] as usize % NODE_VERSIONS.len();
|
||||
let os_idx = hash[3] as usize % OS_TYPES.len();
|
||||
let lang_idx = hash[4] as usize % ACCEPT_LANGUAGES.len();
|
||||
let screen_idx = hash[5] as usize % SCREEN_RESOLUTIONS.len();
|
||||
let color_idx = hash[6] as usize % COLOR_DEPTHS.len();
|
||||
|
||||
let os_type = OS_TYPES[os_idx];
|
||||
let os_version = match os_type {
|
||||
"darwin" => DARWIN_VERSIONS[hash[7] as usize % DARWIN_VERSIONS.len()],
|
||||
"win32" => WIN32_VERSIONS[hash[7] as usize % WIN32_VERSIONS.len()],
|
||||
"linux" => LINUX_VERSIONS[hash[7] as usize % LINUX_VERSIONS.len()],
|
||||
_ => DARWIN_VERSIONS[0],
|
||||
};
|
||||
|
||||
// 生成 hardware_concurrency(4-32)
|
||||
let (min_cores, max_cores) = HARDWARE_CONCURRENCY_RANGE;
|
||||
let cores_range = max_cores - min_cores + 1;
|
||||
let hardware_concurrency = min_cores + (hash[8] % cores_range);
|
||||
|
||||
// 生成 timezone_offset(-720 到 720 分钟)
|
||||
let (min_tz, max_tz) = TIMEZONE_OFFSET_RANGE;
|
||||
let tz_range = (max_tz - min_tz + 1) as u16;
|
||||
let tz_offset = hash[9] as u16 * 256 + hash[10] as u16;
|
||||
let timezone_offset = min_tz + (tz_offset % tz_range) as i16;
|
||||
|
||||
// 生成 kiro_hash(模拟 SHA256)
|
||||
let kiro_hash = sha256_hex(&format!("kiro-{}-{}", KIRO_VERSIONS[kiro_idx], seed));
|
||||
|
||||
// 生成 machine_id
|
||||
let machine_id = sha256_hex(&format!("machine-{}", seed));
|
||||
|
||||
Self {
|
||||
sdk_version: SDK_VERSIONS[sdk_idx].to_string(),
|
||||
os_type: os_type.to_string(),
|
||||
os_version: os_version.to_string(),
|
||||
node_version: NODE_VERSIONS[node_idx].to_string(),
|
||||
kiro_version: KIRO_VERSIONS[kiro_idx].to_string(),
|
||||
kiro_hash,
|
||||
accept_language: ACCEPT_LANGUAGES[lang_idx].to_string(),
|
||||
screen_resolution: SCREEN_RESOLUTIONS[screen_idx].to_string(),
|
||||
color_depth: COLOR_DEPTHS[color_idx],
|
||||
hardware_concurrency,
|
||||
timezone_offset,
|
||||
machine_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成随机指纹
|
||||
///
|
||||
/// 用于测试或不需要确定性的场景
|
||||
#[allow(dead_code)]
|
||||
pub fn generate_random() -> Self {
|
||||
let seed = format!("random-{}", fastrand::u64(..));
|
||||
Self::generate_from_seed(&seed)
|
||||
}
|
||||
|
||||
/// 构建 User-Agent 字符串
|
||||
pub fn user_agent(&self) -> String {
|
||||
format!(
|
||||
"aws-sdk-js/{} ua/2.1 os/{}#{} lang/js md/nodejs#{} api/codewhispererstreaming#{} m/E KiroIDE-{}-{}",
|
||||
self.sdk_version,
|
||||
self.os_type,
|
||||
self.os_version,
|
||||
self.node_version,
|
||||
self.sdk_version,
|
||||
self.kiro_version,
|
||||
self.machine_id
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建 x-amz-user-agent 字符串
|
||||
pub fn x_amz_user_agent(&self) -> String {
|
||||
format!(
|
||||
"aws-sdk-js/{} KiroIDE-{}-{}",
|
||||
self.sdk_version, self.kiro_version, self.machine_id
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取格式化的操作系统字符串(用于 User-Agent)
|
||||
pub fn os_string(&self) -> String {
|
||||
format!("{}#{}", self.os_type, self.os_version)
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA256 哈希(返回字节数组)
|
||||
fn sha256_bytes(input: &str) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// SHA256 哈希(返回十六进制字符串)
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
hex::encode(sha256_bytes(input))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fingerprint_deterministic() {
|
||||
// 相同种子应生成相同指纹
|
||||
let fp1 = Fingerprint::generate_from_seed("test_seed");
|
||||
let fp2 = Fingerprint::generate_from_seed("test_seed");
|
||||
|
||||
assert_eq!(fp1.sdk_version, fp2.sdk_version);
|
||||
assert_eq!(fp1.os_type, fp2.os_type);
|
||||
assert_eq!(fp1.kiro_version, fp2.kiro_version);
|
||||
assert_eq!(fp1.machine_id, fp2.machine_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fingerprint_different_seeds() {
|
||||
// 不同种子应生成不同指纹
|
||||
let fp1 = Fingerprint::generate_from_seed("seed1");
|
||||
let fp2 = Fingerprint::generate_from_seed("seed2");
|
||||
|
||||
// machine_id 一定不同
|
||||
assert_ne!(fp1.machine_id, fp2.machine_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_agent_format() {
|
||||
let fp = Fingerprint::generate_from_seed("test");
|
||||
let ua = fp.user_agent();
|
||||
|
||||
assert!(ua.contains("aws-sdk-js/"));
|
||||
assert!(ua.contains("KiroIDE-"));
|
||||
assert!(ua.contains("lang/js"));
|
||||
assert!(ua.contains("md/nodejs#"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_x_amz_user_agent_format() {
|
||||
let fp = Fingerprint::generate_from_seed("test");
|
||||
let amz_ua = fp.x_amz_user_agent();
|
||||
|
||||
assert!(amz_ua.contains("aws-sdk-js/"));
|
||||
assert!(amz_ua.contains("KiroIDE-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_machine_id_length() {
|
||||
let fp = Fingerprint::generate_from_seed("test");
|
||||
assert_eq!(fp.machine_id.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hardware_concurrency_range() {
|
||||
for i in 0..100 {
|
||||
let fp = Fingerprint::generate_from_seed(&format!("test_{}", i));
|
||||
assert!(fp.hardware_concurrency >= 4);
|
||||
assert!(fp.hardware_concurrency <= 32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timezone_offset_range() {
|
||||
for i in 0..100 {
|
||||
let fp = Fingerprint::generate_from_seed(&format!("test_{}", i));
|
||||
assert!(fp.timezone_offset >= -720);
|
||||
assert!(fp.timezone_offset <= 720);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_os_version_matches_type() {
|
||||
// darwin 类型应该有 darwin 版本
|
||||
let fp = Fingerprint {
|
||||
sdk_version: "1.0.27".to_string(),
|
||||
os_type: "darwin".to_string(),
|
||||
os_version: "24.6.0".to_string(),
|
||||
node_version: "22.21.1".to_string(),
|
||||
kiro_version: "0.8.0".to_string(),
|
||||
kiro_hash: "test".to_string(),
|
||||
accept_language: "en-US".to_string(),
|
||||
screen_resolution: "1920x1080".to_string(),
|
||||
color_depth: 24,
|
||||
hardware_concurrency: 8,
|
||||
timezone_offset: -480,
|
||||
machine_id: "a".repeat(64),
|
||||
};
|
||||
|
||||
let os_str = fp.os_string();
|
||||
assert!(os_str.starts_with("darwin#"));
|
||||
}
|
||||
}
|
||||
171
src/kiro/machine_id.rs
Normal file
171
src/kiro/machine_id.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
//! 设备指纹生成器
|
||||
//!
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::kiro::model::credentials::KiroCredentials;
|
||||
use crate::model::config::Config;
|
||||
|
||||
/// 标准化 machineId 格式
|
||||
///
|
||||
/// 支持以下格式:
|
||||
/// - 64 字符十六进制字符串(直接返回)
|
||||
/// - UUID 格式(如 "2582956e-cc88-4669-b546-07adbffcb894",移除连字符后补齐到 64 字符)
|
||||
fn normalize_machine_id(machine_id: &str) -> Option<String> {
|
||||
let trimmed = machine_id.trim();
|
||||
|
||||
// 如果已经是 64 字符,直接返回
|
||||
if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
|
||||
// 尝试解析 UUID 格式(移除连字符)
|
||||
let without_dashes: String = trimmed.chars().filter(|c| *c != '-').collect();
|
||||
|
||||
// UUID 去掉连字符后是 32 字符
|
||||
if without_dashes.len() == 32 && without_dashes.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
// 补齐到 64 字符(重复一次)
|
||||
return Some(format!("{}{}", without_dashes, without_dashes));
|
||||
}
|
||||
|
||||
// 无法识别的格式
|
||||
None
|
||||
}
|
||||
|
||||
/// 根据凭证信息生成唯一的 Machine ID
|
||||
///
|
||||
/// 优先使用凭据级 machineId,其次使用 config.machineId,然后使用 refreshToken 生成
|
||||
pub fn generate_from_credentials(credentials: &KiroCredentials, config: &Config) -> Option<String> {
|
||||
// 如果配置了凭据级 machineId,优先使用
|
||||
if let Some(ref machine_id) = credentials.machine_id
|
||||
&& let Some(normalized) = normalize_machine_id(machine_id)
|
||||
{
|
||||
return Some(normalized);
|
||||
}
|
||||
|
||||
// 如果配置了全局 machineId,作为默认值
|
||||
if let Some(ref machine_id) = config.machine_id
|
||||
&& let Some(normalized) = normalize_machine_id(machine_id)
|
||||
{
|
||||
return Some(normalized);
|
||||
}
|
||||
|
||||
// 使用 refreshToken 生成
|
||||
if let Some(ref refresh_token) = credentials.refresh_token
|
||||
&& !refresh_token.is_empty()
|
||||
{
|
||||
return Some(sha256_hex(&format!("KotlinNativeAPI/{}", refresh_token)));
|
||||
}
|
||||
|
||||
// 没有有效的凭证
|
||||
None
|
||||
}
|
||||
|
||||
/// SHA256 哈希实现(返回十六进制字符串)
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
hex::encode(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sha256_hex() {
|
||||
let result = sha256_hex("test");
|
||||
assert_eq!(result.len(), 64);
|
||||
assert_eq!(
|
||||
result,
|
||||
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_with_custom_machine_id() {
|
||||
let credentials = KiroCredentials::default();
|
||||
let mut config = Config::default();
|
||||
config.machine_id = Some("a".repeat(64));
|
||||
|
||||
let result = generate_from_credentials(&credentials, &config);
|
||||
assert_eq!(result, Some("a".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_with_credential_machine_id_overrides_config() {
|
||||
let mut credentials = KiroCredentials::default();
|
||||
credentials.machine_id = Some("b".repeat(64));
|
||||
|
||||
let mut config = Config::default();
|
||||
config.machine_id = Some("a".repeat(64));
|
||||
|
||||
let result = generate_from_credentials(&credentials, &config);
|
||||
assert_eq!(result, Some("b".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_with_refresh_token() {
|
||||
let mut credentials = KiroCredentials::default();
|
||||
credentials.refresh_token = Some("test_refresh_token".to_string());
|
||||
let config = Config::default();
|
||||
|
||||
let result = generate_from_credentials(&credentials, &config);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.as_ref().unwrap().len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_without_credentials() {
|
||||
let credentials = KiroCredentials::default();
|
||||
let config = Config::default();
|
||||
|
||||
let result = generate_from_credentials(&credentials, &config);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_uuid_format() {
|
||||
// UUID 格式应该被转换为 64 字符
|
||||
let uuid = "2582956e-cc88-4669-b546-07adbffcb894";
|
||||
let result = normalize_machine_id(uuid);
|
||||
assert!(result.is_some());
|
||||
let normalized = result.unwrap();
|
||||
assert_eq!(normalized.len(), 64);
|
||||
// UUID 去掉连字符后重复一次
|
||||
assert_eq!(
|
||||
normalized,
|
||||
"2582956ecc884669b54607adbffcb8942582956ecc884669b54607adbffcb894"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_64_char_hex() {
|
||||
// 64 字符十六进制应该直接返回
|
||||
let hex64 = "a".repeat(64);
|
||||
let result = normalize_machine_id(&hex64);
|
||||
assert_eq!(result, Some(hex64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_invalid_format() {
|
||||
// 无效格式应该返回 None
|
||||
assert!(normalize_machine_id("invalid").is_none());
|
||||
assert!(normalize_machine_id("too-short").is_none());
|
||||
assert!(normalize_machine_id(&"g".repeat(64)).is_none()); // 非十六进制
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_with_uuid_machine_id() {
|
||||
let mut credentials = KiroCredentials::default();
|
||||
credentials.machine_id = Some("2582956e-cc88-4669-b546-07adbffcb894".to_string());
|
||||
|
||||
let config = Config::default();
|
||||
|
||||
let result = generate_from_credentials(&credentials, &config);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.as_ref().unwrap().len(), 64);
|
||||
}
|
||||
}
|
||||
13
src/kiro/mod.rs
Normal file
13
src/kiro/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! Kiro API 客户端模块
|
||||
|
||||
pub mod affinity;
|
||||
pub mod background_refresh;
|
||||
pub mod cooldown;
|
||||
pub mod fingerprint;
|
||||
pub mod machine_id;
|
||||
pub mod model;
|
||||
pub mod parser;
|
||||
pub mod provider;
|
||||
pub mod rate_limiter;
|
||||
pub mod token_manager;
|
||||
pub mod web_portal;
|
||||
4
src/kiro/model/common/mod.rs
Normal file
4
src/kiro/model/common/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! 共享类型模块
|
||||
//!
|
||||
//! 此模块已简化,移除了未使用的类型定义。
|
||||
//! 如果将来需要扩展,可以在此添加新的共享类型。
|
||||
849
src/kiro/model/credentials.rs
Normal file
849
src/kiro/model/credentials.rs
Normal file
@@ -0,0 +1,849 @@
|
||||
//! Kiro OAuth 凭证数据模型
|
||||
//!
|
||||
//! 支持从 Kiro IDE 的凭证文件加载,使用 Social 认证方式
|
||||
//! 支持单凭据和多凭据配置格式
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::http_client::ProxyConfig;
|
||||
use crate::model::config::Config;
|
||||
|
||||
/// Kiro OAuth 凭证
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KiroCredentials {
|
||||
/// 凭据唯一标识符(自增 ID)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<u64>,
|
||||
|
||||
/// 访问令牌
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub access_token: Option<String>,
|
||||
|
||||
/// 刷新令牌
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
|
||||
/// Profile ARN
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_arn: Option<String>,
|
||||
|
||||
/// 过期时间 (RFC3339 格式)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
|
||||
/// 认证方式 (social / idc)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_method: Option<String>,
|
||||
|
||||
/// OIDC Client ID (IdC 认证需要)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_id: Option<String>,
|
||||
|
||||
/// OIDC Client Secret (IdC 认证需要)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
/// 凭据优先级(数字越小优先级越高,默认为 0)
|
||||
#[serde(default)]
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub priority: u32,
|
||||
|
||||
/// 凭据级 Region 配置(用于 Token 刷新及 API 请求默认值)
|
||||
/// 未配置时回退到 config.json 的全局 region
|
||||
/// 兼容旧配置中的 authRegion 字段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(alias = "authRegion")]
|
||||
pub region: Option<String>,
|
||||
|
||||
/// 凭据级 API Region(用于 API 请求,可单独覆盖 region)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_region: Option<String>,
|
||||
|
||||
/// 凭据级 Machine ID 配置(可选)
|
||||
/// 未配置时回退到 config.json 的 machineId;都未配置时由 refreshToken 派生
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub machine_id: Option<String>,
|
||||
|
||||
/// 用户邮箱(从 Anthropic API 获取)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
|
||||
/// 订阅等级(KIRO PRO+ / KIRO FREE 等)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub subscription_title: Option<String>,
|
||||
|
||||
/// 凭据级代理 URL(可选)
|
||||
/// 支持 http/https/socks5 协议
|
||||
/// 特殊值 "direct" 表示显式不使用代理(即使全局配置了代理)
|
||||
/// 未配置时回退到全局代理配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_url: Option<String>,
|
||||
|
||||
/// 凭据级代理认证用户名(可选)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_username: Option<String>,
|
||||
|
||||
/// 凭据级代理认证密码(可选)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_password: Option<String>,
|
||||
|
||||
/// 凭据是否被禁用(默认为 false)
|
||||
#[serde(default)]
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
/// 判断是否为零(用于跳过序列化)
|
||||
fn is_zero(value: &u32) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
fn canonicalize_auth_method_value(value: &str) -> &str {
|
||||
if value.eq_ignore_ascii_case("builder-id") || value.eq_ignore_ascii_case("iam") {
|
||||
"idc"
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
/// 凭据配置(支持单对象或数组格式)
|
||||
///
|
||||
/// 自动识别配置文件格式:
|
||||
/// - 单对象格式(旧格式,向后兼容)
|
||||
/// - 数组格式(新格式,支持多凭据)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum CredentialsConfig {
|
||||
/// 单个凭据(旧格式)
|
||||
Single(KiroCredentials),
|
||||
/// 多凭据数组(新格式)
|
||||
Multiple(Vec<KiroCredentials>),
|
||||
}
|
||||
|
||||
impl CredentialsConfig {
|
||||
/// 从文件加载凭据配置
|
||||
///
|
||||
/// - 如果文件不存在,返回空数组
|
||||
/// - 如果文件内容为空,返回空数组
|
||||
/// - 支持单对象或数组格式
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
|
||||
let path = path.as_ref();
|
||||
|
||||
// 文件不存在时返回空数组
|
||||
if !path.exists() {
|
||||
return Ok(CredentialsConfig::Multiple(vec![]));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
|
||||
// 文件为空时返回空数组
|
||||
if content.trim().is_empty() {
|
||||
return Ok(CredentialsConfig::Multiple(vec![]));
|
||||
}
|
||||
|
||||
let config = serde_json::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 转换为按优先级排序的凭据列表
|
||||
pub fn into_sorted_credentials(self) -> Vec<KiroCredentials> {
|
||||
match self {
|
||||
CredentialsConfig::Single(mut cred) => {
|
||||
cred.canonicalize_auth_method();
|
||||
vec![cred]
|
||||
}
|
||||
CredentialsConfig::Multiple(mut creds) => {
|
||||
// 按优先级排序(数字越小优先级越高)
|
||||
creds.sort_by_key(|c| c.priority);
|
||||
for cred in &mut creds {
|
||||
cred.canonicalize_auth_method();
|
||||
}
|
||||
creds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取凭据数量
|
||||
#[allow(dead_code)]
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
CredentialsConfig::Single(_) => 1,
|
||||
CredentialsConfig::Multiple(creds) => creds.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为空
|
||||
#[allow(dead_code)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
CredentialsConfig::Single(_) => false,
|
||||
CredentialsConfig::Multiple(creds) => creds.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为多凭据格式(数组格式)
|
||||
pub fn is_multiple(&self) -> bool {
|
||||
matches!(self, CredentialsConfig::Multiple(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl KiroCredentials {
|
||||
/// 特殊值:显式不使用代理
|
||||
pub const PROXY_DIRECT: &'static str = "direct";
|
||||
|
||||
/// 获取默认凭证文件路径
|
||||
#[allow(dead_code)]
|
||||
pub fn default_credentials_path() -> &'static str {
|
||||
"credentials.json"
|
||||
}
|
||||
|
||||
/// 获取有效的 Auth Region(用于 Token 刷新)
|
||||
/// 优先级:凭据.region > config.region
|
||||
pub fn effective_auth_region<'a>(&'a self, config: &'a Config) -> &'a str {
|
||||
self.region
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(&config.region)
|
||||
}
|
||||
|
||||
/// 获取有效的 API Region(用于 API 请求和额度查询)
|
||||
/// 优先级:凭据.api_region > 凭据.region > config.api_region > config.region
|
||||
pub fn effective_api_region<'a>(&'a self, config: &'a Config) -> &'a str {
|
||||
self.api_region
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| self.region.as_deref().filter(|s| !s.trim().is_empty()))
|
||||
.unwrap_or(config.effective_api_region())
|
||||
}
|
||||
|
||||
/// 获取有效的代理配置
|
||||
/// 优先级:凭据代理 > 全局代理 > 无代理
|
||||
/// 特殊值 "direct" 表示显式不使用代理(即使全局配置了代理)
|
||||
pub fn effective_proxy(&self, global_proxy: Option<&ProxyConfig>) -> Option<ProxyConfig> {
|
||||
match self.proxy_url.as_deref() {
|
||||
Some(url) if url.eq_ignore_ascii_case(Self::PROXY_DIRECT) => None,
|
||||
Some(url) => {
|
||||
let mut proxy = ProxyConfig::new(url);
|
||||
if let (Some(username), Some(password)) =
|
||||
(&self.proxy_username, &self.proxy_password)
|
||||
{
|
||||
proxy = proxy.with_auth(username, password);
|
||||
}
|
||||
Some(proxy)
|
||||
}
|
||||
None => global_proxy.cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 JSON 字符串解析凭证
|
||||
#[allow(dead_code)]
|
||||
pub fn from_json(json_string: &str) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_str(json_string)
|
||||
}
|
||||
|
||||
/// 从文件加载凭证
|
||||
#[allow(dead_code)]
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
|
||||
let content = fs::read_to_string(path.as_ref())?;
|
||||
if content.is_empty() {
|
||||
anyhow::bail!("凭证文件为空: {:?}", path.as_ref());
|
||||
}
|
||||
let credentials = Self::from_json(&content)?;
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// 序列化为格式化的 JSON 字符串
|
||||
#[allow(dead_code)]
|
||||
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string_pretty(self)
|
||||
}
|
||||
|
||||
pub fn canonicalize_auth_method(&mut self) {
|
||||
let auth_method = match &self.auth_method {
|
||||
Some(m) => m,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let canonical = canonicalize_auth_method_value(auth_method);
|
||||
if canonical != auth_method {
|
||||
self.auth_method = Some(canonical.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查凭据是否支持 Opus 模型
|
||||
///
|
||||
/// Free 账号不支持 Opus 模型,需要 PRO 或更高等级订阅
|
||||
pub fn supports_opus(&self) -> bool {
|
||||
match &self.subscription_title {
|
||||
Some(title) => {
|
||||
let title_upper = title.to_uppercase();
|
||||
// 如果包含 FREE,则不支持 Opus
|
||||
!title_upper.contains("FREE")
|
||||
}
|
||||
// 如果还没有获取订阅信息,暂时允许(首次使用时会获取)
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::config::Config;
|
||||
|
||||
#[test]
|
||||
fn test_from_json() {
|
||||
let json = r#"{
|
||||
"accessToken": "test_token",
|
||||
"refreshToken": "test_refresh",
|
||||
"profileArn": "arn:aws:test",
|
||||
"expiresAt": "2024-01-01T00:00:00Z",
|
||||
"authMethod": "social"
|
||||
}"#;
|
||||
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.access_token, Some("test_token".to_string()));
|
||||
assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
|
||||
assert_eq!(creds.profile_arn, Some("arn:aws:test".to_string()));
|
||||
assert_eq!(creds.expires_at, Some("2024-01-01T00:00:00Z".to_string()));
|
||||
assert_eq!(creds.auth_method, Some("social".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_json_with_unknown_keys() {
|
||||
let json = r#"{
|
||||
"accessToken": "test_token",
|
||||
"unknownField": "should be ignored"
|
||||
}"#;
|
||||
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.access_token, Some("test_token".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_json() {
|
||||
let creds = KiroCredentials {
|
||||
id: None,
|
||||
access_token: Some("token".to_string()),
|
||||
refresh_token: None,
|
||||
profile_arn: None,
|
||||
expires_at: None,
|
||||
auth_method: Some("social".to_string()),
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
priority: 0,
|
||||
region: None,
|
||||
api_region: None,
|
||||
machine_id: None,
|
||||
email: None,
|
||||
subscription_title: None,
|
||||
proxy_url: None,
|
||||
proxy_username: None,
|
||||
proxy_password: None,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
let json = creds.to_pretty_json().unwrap();
|
||||
assert!(json.contains("accessToken"));
|
||||
assert!(json.contains("authMethod"));
|
||||
assert!(!json.contains("refreshToken"));
|
||||
// priority 为 0 时不序列化
|
||||
assert!(!json.contains("priority"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_credentials_path() {
|
||||
assert_eq!(
|
||||
KiroCredentials::default_credentials_path(),
|
||||
"credentials.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_default() {
|
||||
let json = r#"{"refreshToken": "test"}"#;
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.priority, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_explicit() {
|
||||
let json = r#"{"refreshToken": "test", "priority": 5}"#;
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.priority, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credentials_config_single() {
|
||||
let json = r#"{"refreshToken": "test", "expiresAt": "2025-12-31T00:00:00Z"}"#;
|
||||
let config: CredentialsConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(config, CredentialsConfig::Single(_)));
|
||||
assert_eq!(config.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credentials_config_multiple() {
|
||||
let json = r#"[
|
||||
{"refreshToken": "test1", "priority": 1},
|
||||
{"refreshToken": "test2", "priority": 0}
|
||||
]"#;
|
||||
let config: CredentialsConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(config, CredentialsConfig::Multiple(_)));
|
||||
assert_eq!(config.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credentials_config_priority_sorting() {
|
||||
let json = r#"[
|
||||
{"refreshToken": "t1", "priority": 2},
|
||||
{"refreshToken": "t2", "priority": 0},
|
||||
{"refreshToken": "t3", "priority": 1}
|
||||
]"#;
|
||||
let config: CredentialsConfig = serde_json::from_str(json).unwrap();
|
||||
let list = config.into_sorted_credentials();
|
||||
|
||||
// 验证按优先级排序
|
||||
assert_eq!(list[0].refresh_token, Some("t2".to_string())); // priority 0
|
||||
assert_eq!(list[1].refresh_token, Some("t3".to_string())); // priority 1
|
||||
assert_eq!(list[2].refresh_token, Some("t1".to_string())); // priority 2
|
||||
}
|
||||
|
||||
// ============ Region 字段测试 ============
|
||||
|
||||
#[test]
|
||||
fn test_region_field_parsing() {
|
||||
// 测试解析包含 region 字段的 JSON
|
||||
let json = r#"{
|
||||
"refreshToken": "test_refresh",
|
||||
"region": "us-east-1"
|
||||
}"#;
|
||||
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
|
||||
assert_eq!(creds.region, Some("us-east-1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_region_field_missing_backward_compat() {
|
||||
// 测试向后兼容:不包含 region 字段的旧格式 JSON
|
||||
let json = r#"{
|
||||
"refreshToken": "test_refresh",
|
||||
"authMethod": "social"
|
||||
}"#;
|
||||
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
|
||||
assert_eq!(creds.region, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_region_field_serialization() {
|
||||
// 测试序列化时正确输出 region 字段
|
||||
let creds = KiroCredentials {
|
||||
id: None,
|
||||
access_token: None,
|
||||
refresh_token: Some("test".to_string()),
|
||||
profile_arn: None,
|
||||
expires_at: None,
|
||||
auth_method: None,
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
priority: 0,
|
||||
region: Some("eu-west-1".to_string()),
|
||||
api_region: None,
|
||||
machine_id: None,
|
||||
email: None,
|
||||
subscription_title: None,
|
||||
proxy_url: None,
|
||||
proxy_username: None,
|
||||
proxy_password: None,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
let json = creds.to_pretty_json().unwrap();
|
||||
assert!(json.contains("region"));
|
||||
assert!(json.contains("eu-west-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_region_field_none_not_serialized() {
|
||||
// 测试 region 为 None 时不序列化
|
||||
let creds = KiroCredentials {
|
||||
id: None,
|
||||
access_token: None,
|
||||
refresh_token: Some("test".to_string()),
|
||||
profile_arn: None,
|
||||
expires_at: None,
|
||||
auth_method: None,
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
priority: 0,
|
||||
region: None,
|
||||
api_region: None,
|
||||
machine_id: None,
|
||||
email: None,
|
||||
subscription_title: None,
|
||||
proxy_url: None,
|
||||
proxy_username: None,
|
||||
proxy_password: None,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
let json = creds.to_pretty_json().unwrap();
|
||||
assert!(!json.contains("region"));
|
||||
}
|
||||
|
||||
// ============ MachineId 字段测试 ============
|
||||
|
||||
#[test]
|
||||
fn test_machine_id_field_parsing() {
|
||||
let machine_id = "a".repeat(64);
|
||||
let json = format!(
|
||||
r#"{{
|
||||
"refreshToken": "test_refresh",
|
||||
"machineId": "{machine_id}"
|
||||
}}"#
|
||||
);
|
||||
|
||||
let creds = KiroCredentials::from_json(&json).unwrap();
|
||||
assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
|
||||
assert_eq!(creds.machine_id, Some(machine_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_machine_id_field_serialization() {
|
||||
let creds = KiroCredentials {
|
||||
refresh_token: Some("test".to_string()),
|
||||
machine_id: Some("b".repeat(64)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = creds.to_pretty_json().unwrap();
|
||||
assert!(json.contains("machineId"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_machine_id_field_none_not_serialized() {
|
||||
let creds = KiroCredentials {
|
||||
refresh_token: Some("test".to_string()),
|
||||
machine_id: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = creds.to_pretty_json().unwrap();
|
||||
assert!(!json.contains("machineId"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_credentials_with_different_regions() {
|
||||
// 测试多凭据场景下不同凭据使用各自的 region
|
||||
let json = r#"[
|
||||
{"refreshToken": "t1", "region": "us-east-1"},
|
||||
{"refreshToken": "t2", "region": "eu-west-1"},
|
||||
{"refreshToken": "t3"}
|
||||
]"#;
|
||||
|
||||
let config: CredentialsConfig = serde_json::from_str(json).unwrap();
|
||||
let list = config.into_sorted_credentials();
|
||||
|
||||
assert_eq!(list[0].region, Some("us-east-1".to_string()));
|
||||
assert_eq!(list[1].region, Some("eu-west-1".to_string()));
|
||||
assert_eq!(list[2].region, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_region_field_with_all_fields() {
|
||||
// 测试包含所有字段的完整 JSON
|
||||
let json = r#"{
|
||||
"id": 1,
|
||||
"accessToken": "access",
|
||||
"refreshToken": "refresh",
|
||||
"profileArn": "arn:aws:test",
|
||||
"expiresAt": "2025-12-31T00:00:00Z",
|
||||
"authMethod": "idc",
|
||||
"clientId": "client123",
|
||||
"clientSecret": "secret456",
|
||||
"priority": 5,
|
||||
"region": "ap-northeast-1"
|
||||
}"#;
|
||||
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.id, Some(1));
|
||||
assert_eq!(creds.access_token, Some("access".to_string()));
|
||||
assert_eq!(creds.refresh_token, Some("refresh".to_string()));
|
||||
assert_eq!(creds.profile_arn, Some("arn:aws:test".to_string()));
|
||||
assert_eq!(creds.expires_at, Some("2025-12-31T00:00:00Z".to_string()));
|
||||
assert_eq!(creds.auth_method, Some("idc".to_string()));
|
||||
assert_eq!(creds.client_id, Some("client123".to_string()));
|
||||
assert_eq!(creds.client_secret, Some("secret456".to_string()));
|
||||
assert_eq!(creds.priority, 5);
|
||||
assert_eq!(creds.region, Some("ap-northeast-1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_region_roundtrip() {
|
||||
// 测试序列化和反序列化的往返一致性
|
||||
let original = KiroCredentials {
|
||||
id: Some(42),
|
||||
access_token: Some("token".to_string()),
|
||||
refresh_token: Some("refresh".to_string()),
|
||||
profile_arn: None,
|
||||
expires_at: None,
|
||||
auth_method: Some("social".to_string()),
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
priority: 3,
|
||||
region: Some("us-west-2".to_string()),
|
||||
api_region: None,
|
||||
machine_id: Some("c".repeat(64)),
|
||||
email: None,
|
||||
subscription_title: None,
|
||||
proxy_url: None,
|
||||
proxy_username: None,
|
||||
proxy_password: None,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
let json = original.to_pretty_json().unwrap();
|
||||
let parsed = KiroCredentials::from_json(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.id, original.id);
|
||||
assert_eq!(parsed.access_token, original.access_token);
|
||||
assert_eq!(parsed.refresh_token, original.refresh_token);
|
||||
assert_eq!(parsed.priority, original.priority);
|
||||
assert_eq!(parsed.region, original.region);
|
||||
assert_eq!(parsed.machine_id, original.machine_id);
|
||||
}
|
||||
|
||||
// ============ api_region 字段测试 ============
|
||||
|
||||
#[test]
|
||||
fn test_api_region_field_parsing() {
|
||||
let json = r#"{
|
||||
"refreshToken": "test_refresh",
|
||||
"apiRegion": "ap-southeast-1"
|
||||
}"#;
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.api_region, Some("ap-southeast-1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_region_serialization() {
|
||||
let creds = KiroCredentials {
|
||||
refresh_token: Some("test".to_string()),
|
||||
api_region: Some("us-west-2".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = creds.to_pretty_json().unwrap();
|
||||
assert!(json.contains("apiRegion"));
|
||||
assert!(json.contains("us-west-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_region_none_not_serialized() {
|
||||
let creds = KiroCredentials {
|
||||
refresh_token: Some("test".to_string()),
|
||||
api_region: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = creds.to_pretty_json().unwrap();
|
||||
assert!(!json.contains("apiRegion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_region_roundtrip() {
|
||||
let original = KiroCredentials {
|
||||
refresh_token: Some("refresh".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
api_region: Some("ap-northeast-1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = original.to_pretty_json().unwrap();
|
||||
let parsed = KiroCredentials::from_json(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.region, original.region);
|
||||
assert_eq!(parsed.api_region, original.api_region);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backward_compat_no_api_region() {
|
||||
// 旧格式 JSON 不包含 apiRegion,应正常解析
|
||||
let json = r#"{
|
||||
"refreshToken": "test_refresh",
|
||||
"region": "us-east-1"
|
||||
}"#;
|
||||
let creds = KiroCredentials::from_json(json).unwrap();
|
||||
assert_eq!(creds.region, Some("us-east-1".to_string()));
|
||||
assert_eq!(creds.api_region, None);
|
||||
}
|
||||
|
||||
// ============ effective_auth_region / effective_api_region 优先级测试 ============
|
||||
|
||||
#[test]
|
||||
fn test_effective_auth_region_credential_region_wins() {
|
||||
// 凭据.region > config.region
|
||||
let mut config = Config::default();
|
||||
config.region = "config-region".to_string();
|
||||
|
||||
let creds = KiroCredentials {
|
||||
region: Some("cred-region".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(creds.effective_auth_region(&config), "cred-region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_auth_region_fallback_to_config_region() {
|
||||
let mut config = Config::default();
|
||||
config.region = "config-region".to_string();
|
||||
|
||||
let creds = KiroCredentials::default();
|
||||
|
||||
assert_eq!(creds.effective_auth_region(&config), "config-region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_api_region_credential_api_region_highest() {
|
||||
// 凭据.api_region > 凭据.region > config.api_region > config.region
|
||||
let mut config = Config::default();
|
||||
config.region = "config-region".to_string();
|
||||
config.api_region = Some("config-api-region".to_string());
|
||||
|
||||
let creds = KiroCredentials {
|
||||
region: Some("cred-region".to_string()),
|
||||
api_region: Some("cred-api-region".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(creds.effective_api_region(&config), "cred-api-region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_api_region_fallback_to_credential_region() {
|
||||
let mut config = Config::default();
|
||||
config.region = "config-region".to_string();
|
||||
config.api_region = Some("config-api-region".to_string());
|
||||
|
||||
let creds = KiroCredentials {
|
||||
region: Some("cred-region".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(creds.effective_api_region(&config), "cred-region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_api_region_fallback_to_config_api_region() {
|
||||
let mut config = Config::default();
|
||||
config.region = "config-region".to_string();
|
||||
config.api_region = Some("config-api-region".to_string());
|
||||
|
||||
let creds = KiroCredentials::default();
|
||||
|
||||
assert_eq!(creds.effective_api_region(&config), "config-api-region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_api_region_fallback_to_config_region() {
|
||||
let mut config = Config::default();
|
||||
config.region = "config-region".to_string();
|
||||
|
||||
let creds = KiroCredentials::default();
|
||||
|
||||
assert_eq!(creds.effective_api_region(&config), "config-region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_region_and_api_region_independent() {
|
||||
// region 用于 auth,api_region 可单独覆盖 API 请求
|
||||
let mut config = Config::default();
|
||||
config.region = "default".to_string();
|
||||
|
||||
let creds = KiroCredentials {
|
||||
region: Some("eu-central-1".to_string()),
|
||||
api_region: Some("us-east-1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(creds.effective_auth_region(&config), "eu-central-1");
|
||||
assert_eq!(creds.effective_api_region(&config), "us-east-1");
|
||||
}
|
||||
|
||||
// ============ 凭据级代理优先级测试 ============
|
||||
|
||||
#[test]
|
||||
fn test_effective_proxy_credential_overrides_global() {
|
||||
let global = ProxyConfig::new("http://global:8080");
|
||||
let creds = KiroCredentials {
|
||||
proxy_url: Some("socks5://cred:1080".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = creds.effective_proxy(Some(&global));
|
||||
assert_eq!(result, Some(ProxyConfig::new("socks5://cred:1080")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_proxy_credential_with_auth() {
|
||||
let global = ProxyConfig::new("http://global:8080");
|
||||
let creds = KiroCredentials {
|
||||
proxy_url: Some("http://proxy:3128".to_string()),
|
||||
proxy_username: Some("user".to_string()),
|
||||
proxy_password: Some("pass".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = creds.effective_proxy(Some(&global));
|
||||
let expected = ProxyConfig::new("http://proxy:3128").with_auth("user", "pass");
|
||||
assert_eq!(result, Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_proxy_direct_bypasses_global() {
|
||||
let global = ProxyConfig::new("http://global:8080");
|
||||
let creds = KiroCredentials {
|
||||
proxy_url: Some("direct".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = creds.effective_proxy(Some(&global));
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_proxy_direct_case_insensitive() {
|
||||
let global = ProxyConfig::new("http://global:8080");
|
||||
let creds = KiroCredentials {
|
||||
proxy_url: Some("DIRECT".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = creds.effective_proxy(Some(&global));
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_proxy_fallback_to_global() {
|
||||
let global = ProxyConfig::new("http://global:8080");
|
||||
let creds = KiroCredentials::default();
|
||||
|
||||
let result = creds.effective_proxy(Some(&global));
|
||||
assert_eq!(result, Some(ProxyConfig::new("http://global:8080")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_proxy_none_when_no_proxy() {
|
||||
let creds = KiroCredentials::default();
|
||||
let result = creds.effective_proxy(None);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
}
|
||||
115
src/kiro/model/events/assistant.rs
Normal file
115
src/kiro/model/events/assistant.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
//! 助手响应事件
|
||||
//!
|
||||
//! 处理 assistantResponseEvent 类型的事件
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::kiro::parser::error::ParseResult;
|
||||
use crate::kiro::parser::frame::Frame;
|
||||
|
||||
use super::base::EventPayload;
|
||||
|
||||
/// 助手响应事件
|
||||
///
|
||||
/// 包含 AI 助手的流式响应内容
|
||||
///
|
||||
/// # 设计说明
|
||||
///
|
||||
/// 此结构体只保留实际使用的 `content` 字段,其他 API 返回的字段
|
||||
/// 通过 `#[serde(flatten)]` 捕获到 `extra` 中,确保反序列化不会失败。
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust
|
||||
/// use kiro_rs::kiro::model::events::AssistantResponseEvent;
|
||||
///
|
||||
/// let json = r#"{"content":"Hello, world!"}"#;
|
||||
/// let event: AssistantResponseEvent = serde_json::from_str(json).unwrap();
|
||||
/// assert_eq!(event.content, "Hello, world!");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssistantResponseEvent {
|
||||
/// 响应内容片段
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
|
||||
/// 捕获其他未使用的字段,确保反序列化兼容性
|
||||
#[serde(flatten)]
|
||||
#[serde(skip_serializing)]
|
||||
#[allow(dead_code)]
|
||||
extra: serde_json::Value,
|
||||
}
|
||||
|
||||
impl EventPayload for AssistantResponseEvent {
|
||||
fn from_frame(frame: &Frame) -> ParseResult<Self> {
|
||||
frame.payload_as_json()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AssistantResponseEvent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
content: String::new(),
|
||||
extra: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AssistantResponseEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_simple() {
|
||||
let json = r#"{"content":"Hello, world!"}"#;
|
||||
let event: AssistantResponseEvent = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(event.content, "Hello, world!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_with_extra_fields() {
|
||||
// 确保包含额外字段时反序列化不会失败
|
||||
let json = r#"{
|
||||
"content": "Done",
|
||||
"conversationId": "conv-123",
|
||||
"messageId": "msg-456",
|
||||
"messageStatus": "COMPLETED",
|
||||
"followupPrompt": {
|
||||
"content": "Would you like me to explain further?",
|
||||
"userIntent": "EXPLAIN_CODE_SELECTION"
|
||||
}
|
||||
}"#;
|
||||
let event: AssistantResponseEvent = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(event.content, "Done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_minimal() {
|
||||
let event = AssistantResponseEvent::default();
|
||||
let event = AssistantResponseEvent {
|
||||
content: "Test".to_string(),
|
||||
..event
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"content\":\"Test\""));
|
||||
// extra 字段不应该被序列化
|
||||
assert!(!json.contains("extra"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display() {
|
||||
let event = AssistantResponseEvent {
|
||||
content: "test".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(format!("{}", event), "test");
|
||||
}
|
||||
}
|
||||
186
src/kiro/model/events/base.rs
Normal file
186
src/kiro/model/events/base.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
//! 事件基础定义
|
||||
//!
|
||||
//! 定义事件类型枚举、trait 和统一事件结构
|
||||
|
||||
use crate::kiro::parser::error::{ParseError, ParseResult};
|
||||
use crate::kiro::parser::frame::Frame;
|
||||
|
||||
/// 事件类型枚举
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum EventType {
|
||||
/// 助手响应事件
|
||||
AssistantResponse,
|
||||
/// 工具使用事件
|
||||
ToolUse,
|
||||
/// 计费事件
|
||||
Metering,
|
||||
/// 上下文使用率事件
|
||||
ContextUsage,
|
||||
/// 未知事件类型
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl EventType {
|
||||
/// 从事件类型字符串解析
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"assistantResponseEvent" => Self::AssistantResponse,
|
||||
"toolUseEvent" => Self::ToolUse,
|
||||
"meteringEvent" => Self::Metering,
|
||||
"contextUsageEvent" => Self::ContextUsage,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为事件类型字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AssistantResponse => "assistantResponseEvent",
|
||||
Self::ToolUse => "toolUseEvent",
|
||||
Self::Metering => "meteringEvent",
|
||||
Self::ContextUsage => "contextUsageEvent",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EventType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 事件 payload trait
|
||||
///
|
||||
/// 所有具体事件类型都需要实现此 trait
|
||||
pub trait EventPayload: Sized {
|
||||
/// 从帧解析事件负载
|
||||
fn from_frame(frame: &Frame) -> ParseResult<Self>;
|
||||
}
|
||||
|
||||
/// 统一事件枚举
|
||||
///
|
||||
/// 封装所有可能的事件类型
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
/// 助手响应
|
||||
AssistantResponse(super::AssistantResponseEvent),
|
||||
/// 工具使用
|
||||
ToolUse(super::ToolUseEvent),
|
||||
/// 计费
|
||||
Metering(()),
|
||||
/// 上下文使用率
|
||||
ContextUsage(super::ContextUsageEvent),
|
||||
/// 未知事件 (保留原始帧数据)
|
||||
Unknown {},
|
||||
/// 服务端错误
|
||||
Error {
|
||||
/// 错误代码
|
||||
error_code: String,
|
||||
/// 错误消息
|
||||
error_message: String,
|
||||
},
|
||||
/// 服务端异常
|
||||
Exception {
|
||||
/// 异常类型
|
||||
exception_type: String,
|
||||
/// 异常消息
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// 从帧解析事件
|
||||
pub fn from_frame(frame: Frame) -> ParseResult<Self> {
|
||||
let message_type = frame.message_type().unwrap_or("event");
|
||||
|
||||
match message_type {
|
||||
"event" => Self::parse_event(frame),
|
||||
"error" => Self::parse_error(frame),
|
||||
"exception" => Self::parse_exception(frame),
|
||||
other => Err(ParseError::InvalidMessageType(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析事件类型消息
|
||||
fn parse_event(frame: Frame) -> ParseResult<Self> {
|
||||
let event_type_str = frame.event_type().unwrap_or("unknown");
|
||||
let event_type = EventType::from_str(event_type_str);
|
||||
|
||||
match event_type {
|
||||
EventType::AssistantResponse => {
|
||||
let payload = super::AssistantResponseEvent::from_frame(&frame)?;
|
||||
Ok(Self::AssistantResponse(payload))
|
||||
}
|
||||
EventType::ToolUse => {
|
||||
let payload = super::ToolUseEvent::from_frame(&frame)?;
|
||||
Ok(Self::ToolUse(payload))
|
||||
}
|
||||
EventType::Metering => Ok(Self::Metering(())),
|
||||
EventType::ContextUsage => {
|
||||
let payload = super::ContextUsageEvent::from_frame(&frame)?;
|
||||
Ok(Self::ContextUsage(payload))
|
||||
}
|
||||
EventType::Unknown => Ok(Self::Unknown {}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析错误类型消息
|
||||
fn parse_error(frame: Frame) -> ParseResult<Self> {
|
||||
let error_code = frame
|
||||
.headers
|
||||
.error_code()
|
||||
.unwrap_or("UnknownError")
|
||||
.to_string();
|
||||
let error_message = frame.payload_as_str();
|
||||
|
||||
Ok(Self::Error {
|
||||
error_code,
|
||||
error_message,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析异常类型消息
|
||||
fn parse_exception(frame: Frame) -> ParseResult<Self> {
|
||||
let exception_type = frame
|
||||
.headers
|
||||
.exception_type()
|
||||
.unwrap_or("UnknownException")
|
||||
.to_string();
|
||||
let message = frame.payload_as_str();
|
||||
|
||||
Ok(Self::Exception {
|
||||
exception_type,
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_event_type_from_str() {
|
||||
assert_eq!(
|
||||
EventType::from_str("assistantResponseEvent"),
|
||||
EventType::AssistantResponse
|
||||
);
|
||||
assert_eq!(EventType::from_str("toolUseEvent"), EventType::ToolUse);
|
||||
assert_eq!(EventType::from_str("meteringEvent"), EventType::Metering);
|
||||
assert_eq!(
|
||||
EventType::from_str("contextUsageEvent"),
|
||||
EventType::ContextUsage
|
||||
);
|
||||
assert_eq!(EventType::from_str("unknown_type"), EventType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_type_as_str() {
|
||||
assert_eq!(
|
||||
EventType::AssistantResponse.as_str(),
|
||||
"assistantResponseEvent"
|
||||
);
|
||||
assert_eq!(EventType::ToolUse.as_str(), "toolUseEvent");
|
||||
}
|
||||
}
|
||||
40
src/kiro/model/events/context_usage.rs
Normal file
40
src/kiro/model/events/context_usage.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! 上下文使用率事件
|
||||
//!
|
||||
//! 处理 contextUsageEvent 类型的事件
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::kiro::parser::error::ParseResult;
|
||||
use crate::kiro::parser::frame::Frame;
|
||||
|
||||
use super::base::EventPayload;
|
||||
|
||||
/// 上下文使用率事件
|
||||
///
|
||||
/// 包含当前上下文窗口的使用百分比
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContextUsageEvent {
|
||||
/// 上下文使用百分比 (0-100)
|
||||
#[serde(default)]
|
||||
pub context_usage_percentage: f64,
|
||||
}
|
||||
|
||||
impl EventPayload for ContextUsageEvent {
|
||||
fn from_frame(frame: &Frame) -> ParseResult<Self> {
|
||||
frame.payload_as_json()
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextUsageEvent {
|
||||
/// 获取格式化的百分比字符串
|
||||
pub fn formatted_percentage(&self) -> String {
|
||||
format!("{:.2}%", self.context_usage_percentage)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ContextUsageEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.formatted_percentage())
|
||||
}
|
||||
}
|
||||
13
src/kiro/model/events/mod.rs
Normal file
13
src/kiro/model/events/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! 事件模型
|
||||
//!
|
||||
//! 定义 generateAssistantResponse 流式响应的事件类型
|
||||
|
||||
mod assistant;
|
||||
mod base;
|
||||
mod context_usage;
|
||||
mod tool_use;
|
||||
|
||||
pub use assistant::AssistantResponseEvent;
|
||||
pub use base::Event;
|
||||
pub use context_usage::ContextUsageEvent;
|
||||
pub use tool_use::ToolUseEvent;
|
||||
52
src/kiro/model/events/tool_use.rs
Normal file
52
src/kiro/model/events/tool_use.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! 工具使用事件
|
||||
//!
|
||||
//! 处理 toolUseEvent 类型的事件
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::kiro::parser::error::ParseResult;
|
||||
use crate::kiro::parser::frame::Frame;
|
||||
|
||||
use super::base::EventPayload;
|
||||
|
||||
/// 工具使用事件
|
||||
///
|
||||
/// 包含工具调用的流式数据
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolUseEvent {
|
||||
/// 工具名称
|
||||
pub name: String,
|
||||
/// 工具调用 ID
|
||||
pub tool_use_id: String,
|
||||
/// 工具输入数据 (JSON 字符串,可能是流式的部分数据)
|
||||
#[serde(default)]
|
||||
pub input: String,
|
||||
/// 是否是最后一个块
|
||||
#[serde(default)]
|
||||
pub stop: bool,
|
||||
}
|
||||
|
||||
impl EventPayload for ToolUseEvent {
|
||||
fn from_frame(frame: &Frame) -> ParseResult<Self> {
|
||||
frame.payload_as_json()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolUseEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.stop {
|
||||
write!(
|
||||
f,
|
||||
"ToolUse[{}] (id={}, complete): {}",
|
||||
self.name, self.tool_use_id, self.input
|
||||
)
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"ToolUse[{}] (id={}, partial): {}",
|
||||
self.name, self.tool_use_id, self.input
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/kiro/model/mod.rs
Normal file
16
src/kiro/model/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! Kiro 数据模型
|
||||
//!
|
||||
//! 包含 Kiro API 的所有数据类型定义:
|
||||
//! - `common`: 共享类型(枚举和辅助结构体)
|
||||
//! - `events`: 响应事件类型
|
||||
//! - `requests`: 请求类型
|
||||
//! - `credentials`: OAuth 凭证
|
||||
//! - `token_refresh`: Token 刷新
|
||||
//! - `usage_limits`: 使用额度查询
|
||||
|
||||
pub mod common;
|
||||
pub mod credentials;
|
||||
pub mod events;
|
||||
pub mod requests;
|
||||
pub mod token_refresh;
|
||||
pub mod usage_limits;
|
||||
408
src/kiro/model/requests/conversation.rs
Normal file
408
src/kiro/model/requests/conversation.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
//! 对话类型定义
|
||||
//!
|
||||
//! 定义 Kiro API 中对话相关的类型,包括消息、历史记录等
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::tool::{Tool, ToolResult, ToolUseEntry};
|
||||
|
||||
/// 对话状态
|
||||
///
|
||||
/// Kiro API 请求中的核心结构,包含当前消息和历史记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConversationState {
|
||||
/// 代理延续 ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_continuation_id: Option<String>,
|
||||
/// 代理任务类型(通常为 "vibe")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_task_type: Option<String>,
|
||||
/// 聊天触发类型("MANUAL" 或 "AUTO")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chat_trigger_type: Option<String>,
|
||||
/// 当前消息
|
||||
pub current_message: CurrentMessage,
|
||||
/// 会话 ID
|
||||
pub conversation_id: String,
|
||||
/// 历史消息列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub history: Vec<Message>,
|
||||
}
|
||||
|
||||
impl ConversationState {
|
||||
/// 创建新的对话状态
|
||||
pub fn new(conversation_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
agent_continuation_id: None,
|
||||
agent_task_type: None,
|
||||
chat_trigger_type: None,
|
||||
current_message: CurrentMessage::default(),
|
||||
conversation_id: conversation_id.into(),
|
||||
history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置代理延续 ID
|
||||
pub fn with_agent_continuation_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.agent_continuation_id = Some(id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置代理任务类型
|
||||
pub fn with_agent_task_type(mut self, task_type: impl Into<String>) -> Self {
|
||||
self.agent_task_type = Some(task_type.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置聊天触发类型
|
||||
pub fn with_chat_trigger_type(mut self, trigger_type: impl Into<String>) -> Self {
|
||||
self.chat_trigger_type = Some(trigger_type.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置当前消息
|
||||
pub fn with_current_message(mut self, message: CurrentMessage) -> Self {
|
||||
self.current_message = message;
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加历史消息
|
||||
pub fn with_history(mut self, history: Vec<Message>) -> Self {
|
||||
self.history = history;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前消息容器
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurrentMessage {
|
||||
/// 用户输入消息
|
||||
pub user_input_message: UserInputMessage,
|
||||
}
|
||||
|
||||
impl CurrentMessage {
|
||||
/// 创建新的当前消息
|
||||
pub fn new(user_input_message: UserInputMessage) -> Self {
|
||||
Self { user_input_message }
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户输入消息
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserInputMessage {
|
||||
/// 用户输入消息上下文
|
||||
pub user_input_message_context: UserInputMessageContext,
|
||||
/// 消息内容
|
||||
pub content: String,
|
||||
/// 模型 ID
|
||||
pub model_id: String,
|
||||
/// 图片列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub images: Vec<KiroImage>,
|
||||
/// 消息来源(通常为 "AI_EDITOR")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<String>,
|
||||
}
|
||||
|
||||
impl UserInputMessage {
|
||||
/// 创建新的用户输入消息
|
||||
pub fn new(content: impl Into<String>, model_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
user_input_message_context: UserInputMessageContext::default(),
|
||||
content: content.into(),
|
||||
model_id: model_id.into(),
|
||||
images: Vec::new(),
|
||||
origin: Some("AI_EDITOR".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置消息上下文
|
||||
pub fn with_context(mut self, context: UserInputMessageContext) -> Self {
|
||||
self.user_input_message_context = context;
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加图片
|
||||
pub fn with_images(mut self, images: Vec<KiroImage>) -> Self {
|
||||
self.images = images;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置来源
|
||||
pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
|
||||
self.origin = Some(origin.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户输入消息上下文
|
||||
///
|
||||
/// 包含工具定义和工具执行结果
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserInputMessageContext {
|
||||
/// 工具执行结果列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tool_results: Vec<ToolResult>,
|
||||
/// 可用工具列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<Tool>,
|
||||
}
|
||||
|
||||
impl UserInputMessageContext {
|
||||
/// 创建新的消息上下文
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 设置工具列表
|
||||
pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
|
||||
self.tools = tools;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置工具结果
|
||||
pub fn with_tool_results(mut self, results: Vec<ToolResult>) -> Self {
|
||||
self.tool_results = results;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Kiro 图片
|
||||
///
|
||||
/// API 中使用的图片格式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KiroImage {
|
||||
/// 图片格式("jpeg", "png", "gif", "webp")
|
||||
pub format: String,
|
||||
/// 图片数据源
|
||||
pub source: KiroImageSource,
|
||||
}
|
||||
|
||||
impl KiroImage {
|
||||
/// 从 base64 数据创建图片
|
||||
pub fn from_base64(format: impl Into<String>, data: impl Into<String>) -> Self {
|
||||
Self {
|
||||
format: format.into(),
|
||||
source: KiroImageSource { bytes: data.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kiro 图片数据源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KiroImageSource {
|
||||
/// base64 编码的图片数据
|
||||
pub bytes: String,
|
||||
}
|
||||
|
||||
/// 历史消息
|
||||
///
|
||||
/// 可以是用户消息或助手消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Message {
|
||||
/// 用户消息
|
||||
User(HistoryUserMessage),
|
||||
/// 助手消息
|
||||
Assistant(HistoryAssistantMessage),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Message {
|
||||
/// 创建用户消息
|
||||
pub fn user(content: impl Into<String>, model_id: impl Into<String>) -> Self {
|
||||
Self::User(HistoryUserMessage::new(content, model_id))
|
||||
}
|
||||
|
||||
/// 创建助手消息
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self::Assistant(HistoryAssistantMessage::new(content))
|
||||
}
|
||||
|
||||
/// 判断是否为用户消息
|
||||
pub fn is_user(&self) -> bool {
|
||||
matches!(self, Self::User(_))
|
||||
}
|
||||
|
||||
/// 判断是否为助手消息
|
||||
pub fn is_assistant(&self) -> bool {
|
||||
matches!(self, Self::Assistant(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史用户消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HistoryUserMessage {
|
||||
/// 用户输入消息
|
||||
pub user_input_message: UserMessage,
|
||||
}
|
||||
|
||||
impl HistoryUserMessage {
|
||||
/// 创建新的历史用户消息
|
||||
pub fn new(content: impl Into<String>, model_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
user_input_message: UserMessage::new(content, model_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户消息(历史记录中使用)
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserMessage {
|
||||
/// 消息内容
|
||||
pub content: String,
|
||||
/// 模型 ID
|
||||
pub model_id: String,
|
||||
/// 消息来源
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<String>,
|
||||
/// 图片列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub images: Vec<KiroImage>,
|
||||
/// 用户输入消息上下文
|
||||
#[serde(default, skip_serializing_if = "is_default_context")]
|
||||
pub user_input_message_context: UserInputMessageContext,
|
||||
}
|
||||
|
||||
fn is_default_context(ctx: &UserInputMessageContext) -> bool {
|
||||
ctx.tools.is_empty() && ctx.tool_results.is_empty()
|
||||
}
|
||||
|
||||
impl UserMessage {
|
||||
/// 创建新的用户消息
|
||||
pub fn new(content: impl Into<String>, model_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
model_id: model_id.into(),
|
||||
origin: Some("AI_EDITOR".to_string()),
|
||||
images: Vec::new(),
|
||||
user_input_message_context: UserInputMessageContext::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置图片
|
||||
pub fn with_images(mut self, images: Vec<KiroImage>) -> Self {
|
||||
self.images = images;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置上下文
|
||||
pub fn with_context(mut self, context: UserInputMessageContext) -> Self {
|
||||
self.user_input_message_context = context;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史助手消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HistoryAssistantMessage {
|
||||
/// 助手响应消息
|
||||
pub assistant_response_message: AssistantMessage,
|
||||
}
|
||||
|
||||
impl HistoryAssistantMessage {
|
||||
/// 创建新的历史助手消息
|
||||
pub fn new(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
assistant_response_message: AssistantMessage::new(content),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 助手消息(历史记录中使用)
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssistantMessage {
|
||||
/// 响应内容
|
||||
pub content: String,
|
||||
/// 工具使用列表
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_uses: Option<Vec<ToolUseEntry>>,
|
||||
}
|
||||
|
||||
impl AssistantMessage {
|
||||
/// 创建新的助手消息
|
||||
pub fn new(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
content: content.into(),
|
||||
tool_uses: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置工具使用
|
||||
pub fn with_tool_uses(mut self, tool_uses: Vec<ToolUseEntry>) -> Self {
|
||||
self.tool_uses = Some(tool_uses);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_conversation_state_new() {
|
||||
let state = ConversationState::new("conv-123")
|
||||
.with_agent_task_type("vibe")
|
||||
.with_chat_trigger_type("MANUAL");
|
||||
|
||||
assert_eq!(state.conversation_id, "conv-123");
|
||||
assert_eq!(state.agent_task_type, Some("vibe".to_string()));
|
||||
assert_eq!(state.chat_trigger_type, Some("MANUAL".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_input_message() {
|
||||
let msg = UserInputMessage::new("Hello", "claude-3-5-sonnet").with_origin("AI_EDITOR");
|
||||
|
||||
assert_eq!(msg.content, "Hello");
|
||||
assert_eq!(msg.model_id, "claude-3-5-sonnet");
|
||||
assert_eq!(msg.origin, Some("AI_EDITOR".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_enum() {
|
||||
let user_msg = Message::user("Hello", "model-id");
|
||||
assert!(user_msg.is_user());
|
||||
assert!(!user_msg.is_assistant());
|
||||
|
||||
let assistant_msg = Message::assistant("Hi there!");
|
||||
assert!(assistant_msg.is_assistant());
|
||||
assert!(!assistant_msg.is_user());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_serialize() {
|
||||
let history = vec![
|
||||
Message::user("Hello", "claude-3-5-sonnet"),
|
||||
Message::assistant("Hi! How can I help you?"),
|
||||
];
|
||||
|
||||
let json = serde_json::to_string(&history).unwrap();
|
||||
assert!(json.contains("userInputMessage"));
|
||||
assert!(json.contains("assistantResponseMessage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_state_serialize() {
|
||||
let state = ConversationState::new("conv-123")
|
||||
.with_agent_task_type("vibe")
|
||||
.with_current_message(CurrentMessage::new(UserInputMessage::new(
|
||||
"Hello",
|
||||
"claude-3-5-sonnet",
|
||||
)));
|
||||
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
assert!(json.contains("\"conversationId\":\"conv-123\""));
|
||||
assert!(json.contains("\"agentTaskType\":\"vibe\""));
|
||||
assert!(json.contains("\"content\":\"Hello\""));
|
||||
}
|
||||
}
|
||||
68
src/kiro/model/requests/kiro.rs
Normal file
68
src/kiro/model/requests/kiro.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Kiro 请求类型定义
|
||||
//!
|
||||
//! 定义 Kiro API 的主请求结构
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::conversation::ConversationState;
|
||||
|
||||
/// Kiro API 请求
|
||||
///
|
||||
/// 用于构建发送给 Kiro API 的请求
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust
|
||||
/// use kiro_rs::kiro::model::requests::{
|
||||
/// KiroRequest, ConversationState, CurrentMessage, UserInputMessage, Tool
|
||||
/// };
|
||||
///
|
||||
/// // 创建简单请求
|
||||
/// let state = ConversationState::new("conv-123")
|
||||
/// .with_agent_task_type("vibe")
|
||||
/// .with_current_message(CurrentMessage::new(
|
||||
/// UserInputMessage::new("Hello", "claude-3-5-sonnet")
|
||||
/// ));
|
||||
///
|
||||
/// let request = KiroRequest::new(state);
|
||||
/// let json = request.to_json().unwrap();
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KiroRequest {
|
||||
/// 对话状态
|
||||
pub conversation_state: ConversationState,
|
||||
/// Profile ARN(可选)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_arn: Option<String>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_kiro_request_deserialize() {
|
||||
let json = r#"{
|
||||
"conversationState": {
|
||||
"conversationId": "conv-456",
|
||||
"currentMessage": {
|
||||
"userInputMessage": {
|
||||
"content": "Test message",
|
||||
"modelId": "claude-3-5-sonnet",
|
||||
"userInputMessageContext": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let request: KiroRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(request.conversation_state.conversation_id, "conv-456");
|
||||
assert_eq!(
|
||||
request
|
||||
.conversation_state
|
||||
.current_message
|
||||
.user_input_message
|
||||
.content,
|
||||
"Test message"
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/kiro/model/requests/mod.rs
Normal file
7
src/kiro/model/requests/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! 请求类型模块
|
||||
//!
|
||||
//! 包含 Kiro API 请求相关的类型定义
|
||||
|
||||
pub mod conversation;
|
||||
pub mod kiro;
|
||||
pub mod tool;
|
||||
192
src/kiro/model/requests/tool.rs
Normal file
192
src/kiro/model/requests/tool.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
//! 工具类型定义
|
||||
//!
|
||||
//! 定义 Kiro API 中工具相关的类型
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 工具定义
|
||||
///
|
||||
/// 用于在请求中定义可用的工具
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tool {
|
||||
/// 工具规范
|
||||
pub tool_specification: ToolSpecification,
|
||||
}
|
||||
|
||||
/// 工具规范
|
||||
///
|
||||
/// 定义工具的名称、描述和输入模式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolSpecification {
|
||||
/// 工具名称
|
||||
pub name: String,
|
||||
/// 工具描述
|
||||
pub description: String,
|
||||
/// 输入模式(JSON Schema)
|
||||
pub input_schema: InputSchema,
|
||||
}
|
||||
|
||||
/// 输入模式
|
||||
///
|
||||
/// 包装 JSON Schema 定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InputSchema {
|
||||
/// JSON Schema 定义
|
||||
pub json: serde_json::Value,
|
||||
}
|
||||
|
||||
impl Default for InputSchema {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
json: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InputSchema {
|
||||
/// 从 JSON 值创建
|
||||
pub fn from_json(json: serde_json::Value) -> Self {
|
||||
Self { json }
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具执行结果
|
||||
///
|
||||
/// 用于返回工具执行的结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolResult {
|
||||
/// 工具使用 ID(与请求中的 tool_use_id 对应)
|
||||
pub tool_use_id: String,
|
||||
/// 结果内容(数组格式)
|
||||
pub content: Vec<serde_json::Map<String, serde_json::Value>>,
|
||||
/// 执行状态("success" 或 "error")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
/// 是否为错误
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// 创建成功的工具结果
|
||||
pub fn success(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(
|
||||
"text".to_string(),
|
||||
serde_json::Value::String(content.into()),
|
||||
);
|
||||
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
content: vec![map],
|
||||
status: Some("success".to_string()),
|
||||
is_error: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建错误的工具结果
|
||||
pub fn error(tool_use_id: impl Into<String>, error_message: impl Into<String>) -> Self {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(
|
||||
"text".to_string(),
|
||||
serde_json::Value::String(error_message.into()),
|
||||
);
|
||||
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
content: vec![map],
|
||||
status: Some("error".to_string()),
|
||||
is_error: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具使用条目
|
||||
///
|
||||
/// 用于历史消息中记录工具调用
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolUseEntry {
|
||||
/// 工具使用 ID
|
||||
pub tool_use_id: String,
|
||||
/// 工具名称
|
||||
pub name: String,
|
||||
/// 工具输入参数
|
||||
pub input: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ToolUseEntry {
|
||||
/// 创建新的工具使用条目
|
||||
pub fn new(tool_use_id: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
name: name.into(),
|
||||
input: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置输入参数
|
||||
pub fn with_input(mut self, input: serde_json::Value) -> Self {
|
||||
self.input = input;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tool_result_success() {
|
||||
let result = ToolResult::success("tool-123", "Operation completed");
|
||||
|
||||
assert!(!result.is_error);
|
||||
assert_eq!(result.status, Some("success".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_result_error() {
|
||||
let result = ToolResult::error("tool-456", "File not found");
|
||||
|
||||
assert!(result.is_error);
|
||||
assert_eq!(result.status, Some("error".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_result_serialize() {
|
||||
let result = ToolResult::success("tool-789", "Done");
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
|
||||
assert!(json.contains("\"toolUseId\":\"tool-789\""));
|
||||
assert!(json.contains("\"status\":\"success\""));
|
||||
// is_error = false 应该被跳过
|
||||
assert!(!json.contains("isError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_use_entry() {
|
||||
let entry = ToolUseEntry::new("use-123", "read_file")
|
||||
.with_input(serde_json::json!({"path": "/test.txt"}));
|
||||
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("\"toolUseId\":\"use-123\""));
|
||||
assert!(json.contains("\"name\":\"read_file\""));
|
||||
assert!(json.contains("\"path\":\"/test.txt\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_schema_default() {
|
||||
let schema = InputSchema::default();
|
||||
assert_eq!(schema.json["type"], "object");
|
||||
}
|
||||
}
|
||||
44
src/kiro/model/token_refresh.rs
Normal file
44
src/kiro/model/token_refresh.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 刷新 Token 的请求体 (Social 认证)
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// 刷新 Token 的响应体 (Social 认证)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefreshResponse {
|
||||
pub access_token: String,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub profile_arn: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expires_in: Option<i64>,
|
||||
}
|
||||
|
||||
/// IdC Token 刷新请求体 (AWS SSO OIDC)
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IdcRefreshRequest {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub refresh_token: String,
|
||||
pub grant_type: String,
|
||||
}
|
||||
|
||||
/// IdC Token 刷新响应体 (AWS SSO OIDC)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IdcRefreshResponse {
|
||||
pub access_token: String,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
// #[serde(default)]
|
||||
// pub token_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expires_in: Option<i64>,
|
||||
}
|
||||
213
src/kiro/model/usage_limits.rs
Normal file
213
src/kiro/model/usage_limits.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! 使用额度查询数据模型
|
||||
//!
|
||||
//! 包含 getUsageLimits API 的响应类型定义
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// 使用额度查询响应
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageLimitsResponse {
|
||||
/// 下次重置日期 (Unix 时间戳)
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub next_date_reset: Option<f64>,
|
||||
|
||||
/// 订阅信息
|
||||
#[serde(default)]
|
||||
pub subscription_info: Option<SubscriptionInfo>,
|
||||
|
||||
/// 使用量明细列表
|
||||
#[serde(default)]
|
||||
pub usage_breakdown_list: Vec<UsageBreakdown>,
|
||||
}
|
||||
|
||||
/// 订阅信息
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionInfo {
|
||||
/// 订阅标题 (KIRO PRO+ / KIRO FREE 等)
|
||||
#[serde(default)]
|
||||
pub subscription_title: Option<String>,
|
||||
}
|
||||
|
||||
/// 使用量明细
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageBreakdown {
|
||||
/// 当前使用量
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub current_usage: i64,
|
||||
|
||||
/// 当前使用量(精确值)
|
||||
#[serde(default)]
|
||||
pub current_usage_with_precision: f64,
|
||||
|
||||
/// 奖励额度列表(可能为 null)
|
||||
#[serde(default)]
|
||||
pub bonuses: Option<Vec<Bonus>>,
|
||||
|
||||
/// 免费试用信息
|
||||
#[serde(default)]
|
||||
pub free_trial_info: Option<FreeTrialInfo>,
|
||||
|
||||
/// 下次重置日期 (Unix 时间戳)
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub next_date_reset: Option<f64>,
|
||||
|
||||
/// 使用限额
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub usage_limit: i64,
|
||||
|
||||
/// 使用限额(精确值)
|
||||
#[serde(default)]
|
||||
pub usage_limit_with_precision: f64,
|
||||
}
|
||||
|
||||
/// 奖励额度
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Bonus {
|
||||
/// 当前使用量
|
||||
#[serde(default)]
|
||||
pub current_usage: f64,
|
||||
|
||||
/// 使用限额
|
||||
#[serde(default)]
|
||||
pub usage_limit: f64,
|
||||
|
||||
/// 状态 (ACTIVE / EXPIRED)
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
impl Bonus {
|
||||
/// 检查 bonus 是否处于激活状态(大小写不敏感)
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.status
|
||||
.as_deref()
|
||||
.map(|s| s.eq_ignore_ascii_case("ACTIVE"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 免费试用信息
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FreeTrialInfo {
|
||||
/// 当前使用量
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub current_usage: i64,
|
||||
|
||||
/// 当前使用量(精确值)
|
||||
#[serde(default)]
|
||||
pub current_usage_with_precision: f64,
|
||||
|
||||
/// 免费试用过期时间 (Unix 时间戳)
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub free_trial_expiry: Option<f64>,
|
||||
|
||||
/// 免费试用状态 (ACTIVE / EXPIRED)
|
||||
#[serde(default)]
|
||||
pub free_trial_status: Option<String>,
|
||||
|
||||
/// 使用限额
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub usage_limit: i64,
|
||||
|
||||
/// 使用限额(精确值)
|
||||
#[serde(default)]
|
||||
pub usage_limit_with_precision: f64,
|
||||
}
|
||||
|
||||
// ============ 便捷方法实现 ============
|
||||
|
||||
impl FreeTrialInfo {
|
||||
/// 检查免费试用是否处于激活状态
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.free_trial_status
|
||||
.as_deref()
|
||||
.map(|s| s == "ACTIVE")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageLimitsResponse {
|
||||
/// 获取订阅标题
|
||||
pub fn subscription_title(&self) -> Option<&str> {
|
||||
self.subscription_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.subscription_title.as_deref())
|
||||
}
|
||||
|
||||
/// 获取第一个使用量明细
|
||||
fn primary_breakdown(&self) -> Option<&UsageBreakdown> {
|
||||
self.usage_breakdown_list.first()
|
||||
}
|
||||
|
||||
/// 获取总使用限额(精确值)
|
||||
///
|
||||
/// 累加基础额度、激活的免费试用额度和激活的奖励额度
|
||||
#[allow(clippy::collapsible_if)]
|
||||
pub fn usage_limit(&self) -> f64 {
|
||||
let Some(breakdown) = self.primary_breakdown() else {
|
||||
return 0.0;
|
||||
};
|
||||
|
||||
let mut total = breakdown.usage_limit_with_precision;
|
||||
|
||||
// 累加激活的 free trial 额度
|
||||
if let Some(trial) = &breakdown.free_trial_info {
|
||||
if trial.is_active() {
|
||||
total += trial.usage_limit_with_precision;
|
||||
}
|
||||
}
|
||||
|
||||
// 累加激活的 bonus 额度
|
||||
if let Some(bonuses) = &breakdown.bonuses {
|
||||
for bonus in bonuses {
|
||||
if bonus.is_active() {
|
||||
total += bonus.usage_limit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
/// 获取总当前使用量(精确值)
|
||||
///
|
||||
/// 累加基础使用量、激活的免费试用使用量和激活的奖励使用量
|
||||
#[allow(clippy::collapsible_if)]
|
||||
pub fn current_usage(&self) -> f64 {
|
||||
let Some(breakdown) = self.primary_breakdown() else {
|
||||
return 0.0;
|
||||
};
|
||||
|
||||
let mut total = breakdown.current_usage_with_precision;
|
||||
|
||||
// 累加激活的 free trial 使用量
|
||||
if let Some(trial) = &breakdown.free_trial_info {
|
||||
if trial.is_active() {
|
||||
total += trial.current_usage_with_precision;
|
||||
}
|
||||
}
|
||||
|
||||
// 累加激活的 bonus 使用量
|
||||
if let Some(bonuses) = &breakdown.bonuses {
|
||||
for bonus in bonuses {
|
||||
if bonus.is_active() {
|
||||
total += bonus.current_usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
}
|
||||
37
src/kiro/parser/crc.rs
Normal file
37
src/kiro/parser/crc.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! CRC32 校验实现
|
||||
//!
|
||||
//! AWS Event Stream 使用 CRC32 (ISO-HDLC/以太网/ZIP 标准)
|
||||
|
||||
use crc::{CRC_32_ISO_HDLC, Crc};
|
||||
|
||||
/// CRC32 计算器实例 (ISO-HDLC 标准,多项式 0xEDB88320)
|
||||
const CRC32: Crc<u32> = Crc::<u32>::new(&CRC_32_ISO_HDLC);
|
||||
|
||||
/// 计算 CRC32 校验和 (ISO-HDLC 标准)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - 要计算校验和的数据
|
||||
///
|
||||
/// # Returns
|
||||
/// CRC32 校验和值
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
CRC32.checksum(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_crc32_empty() {
|
||||
// 空数据的 CRC32 应该是 0
|
||||
assert_eq!(crc32(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crc32_known_value() {
|
||||
// "123456789" 的 CRC32 (ISO-HDLC) 值是 0xCBF43926
|
||||
let data = b"123456789";
|
||||
assert_eq!(crc32(data), 0xCBF43926);
|
||||
}
|
||||
}
|
||||
476
src/kiro/parser/decoder.rs
Normal file
476
src/kiro/parser/decoder.rs
Normal file
@@ -0,0 +1,476 @@
|
||||
//! AWS Event Stream 流式解码器
|
||||
//!
|
||||
//! 使用状态机处理流式数据,支持断点续传和容错处理
|
||||
//!
|
||||
//! ## 状态机设计
|
||||
//!
|
||||
//! 参考 kiro-kt 项目的状态机设计,采用四态模型:
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────┐
|
||||
//! │ Ready │ (初始态,就绪接收数据)
|
||||
//! └────────┬────────┘
|
||||
//! │ feed() 提供数据
|
||||
//! ↓
|
||||
//! ┌─────────────────┐
|
||||
//! │ Parsing │ decode() 尝试解析
|
||||
//! └────────┬────────┘
|
||||
//! │
|
||||
//! ┌────┴────────────┐
|
||||
//! ↓ ↓
|
||||
//! [成功] [失败]
|
||||
//! │ │
|
||||
//! ↓ ├─> error_count++
|
||||
//! ┌─────────┐ │
|
||||
//! │ Ready │ ├─> error_count < max_errors?
|
||||
//! └─────────┘ │ YES → Recovering → Ready
|
||||
//! │ NO ↓
|
||||
//! ┌────────────┐
|
||||
//! │ Stopped │ (终止态)
|
||||
//! └────────────┘
|
||||
//! ```
|
||||
|
||||
use super::error::{ParseError, ParseResult};
|
||||
use super::frame::{Frame, PRELUDE_SIZE, parse_frame};
|
||||
use bytes::{Buf, BytesMut};
|
||||
|
||||
/// 默认最大缓冲区大小 (16 MB)
|
||||
pub const DEFAULT_MAX_BUFFER_SIZE: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// 默认最大连续错误数
|
||||
pub const DEFAULT_MAX_ERRORS: usize = 5;
|
||||
|
||||
/// 默认初始缓冲区容量
|
||||
pub const DEFAULT_BUFFER_CAPACITY: usize = 8192;
|
||||
|
||||
/// 解码器状态
|
||||
///
|
||||
/// 采用四态模型,参考 kiro-kt 的设计:
|
||||
/// - Ready: 就绪状态,可以接收数据
|
||||
/// - Parsing: 正在解析帧
|
||||
/// - Recovering: 恢复中(尝试跳过损坏数据)
|
||||
/// - Stopped: 已停止(错误过多,终止态)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecoderState {
|
||||
/// 就绪,可以接收数据
|
||||
Ready,
|
||||
/// 正在解析帧
|
||||
Parsing,
|
||||
/// 恢复中(跳过损坏数据)
|
||||
Recovering,
|
||||
/// 已停止(错误过多)
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// 流式事件解码器
|
||||
///
|
||||
/// 用于从字节流中解析 AWS Event Stream 消息帧
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use kiro_rs::kiro::parser::EventStreamDecoder;
|
||||
///
|
||||
/// let mut decoder = EventStreamDecoder::new();
|
||||
///
|
||||
/// // 提供流数据
|
||||
/// decoder.feed(chunk)?;
|
||||
///
|
||||
/// // 解码所有可用帧
|
||||
/// for result in decoder.decode_iter() {
|
||||
/// match result {
|
||||
/// Ok(frame) => println!("Got frame: {:?}", frame.event_type()),
|
||||
/// Err(e) => eprintln!("Parse error: {}", e),
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub struct EventStreamDecoder {
|
||||
/// 内部缓冲区
|
||||
buffer: BytesMut,
|
||||
/// 当前状态
|
||||
state: DecoderState,
|
||||
/// 已处理的帧数量
|
||||
frames_decoded: usize,
|
||||
/// 连续错误计数
|
||||
error_count: usize,
|
||||
/// 最大连续错误数
|
||||
max_errors: usize,
|
||||
/// 最大缓冲区大小
|
||||
max_buffer_size: usize,
|
||||
/// 跳过的字节数(用于调试)
|
||||
bytes_skipped: usize,
|
||||
}
|
||||
|
||||
impl Default for EventStreamDecoder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventStreamDecoder {
|
||||
/// 创建新的解码器
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(DEFAULT_BUFFER_CAPACITY)
|
||||
}
|
||||
|
||||
/// 创建具有指定缓冲区大小的解码器
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: BytesMut::with_capacity(capacity),
|
||||
state: DecoderState::Ready,
|
||||
frames_decoded: 0,
|
||||
error_count: 0,
|
||||
max_errors: DEFAULT_MAX_ERRORS,
|
||||
max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
|
||||
bytes_skipped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建具有自定义配置的解码器
|
||||
#[allow(dead_code)]
|
||||
pub fn with_config(capacity: usize, max_errors: usize, max_buffer_size: usize) -> Self {
|
||||
Self {
|
||||
buffer: BytesMut::with_capacity(capacity),
|
||||
state: DecoderState::Ready,
|
||||
frames_decoded: 0,
|
||||
error_count: 0,
|
||||
max_errors,
|
||||
max_buffer_size,
|
||||
bytes_skipped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 向解码器提供数据
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` - 数据已添加到缓冲区
|
||||
/// - `Err(BufferOverflow)` - 缓冲区已满
|
||||
pub fn feed(&mut self, data: &[u8]) -> ParseResult<()> {
|
||||
// 检查缓冲区大小限制
|
||||
let new_size = self.buffer.len() + data.len();
|
||||
if new_size > self.max_buffer_size {
|
||||
return Err(ParseError::BufferOverflow {
|
||||
size: new_size,
|
||||
max: self.max_buffer_size,
|
||||
});
|
||||
}
|
||||
|
||||
self.buffer.extend_from_slice(data);
|
||||
|
||||
// 从 Recovering 状态恢复到 Ready
|
||||
if self.state == DecoderState::Recovering {
|
||||
self.state = DecoderState::Ready;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 尝试解码下一个帧
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(Some(frame))` - 成功解码一个帧
|
||||
/// - `Ok(None)` - 数据不足,需要更多数据
|
||||
/// - `Err(e)` - 解码错误
|
||||
pub fn decode(&mut self) -> ParseResult<Option<Frame>> {
|
||||
// 如果已停止,直接返回错误
|
||||
if self.state == DecoderState::Stopped {
|
||||
return Err(ParseError::TooManyErrors {
|
||||
count: self.error_count,
|
||||
last_error: "解码器已停止".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 缓冲区为空,保持 Ready 状态
|
||||
if self.buffer.is_empty() {
|
||||
self.state = DecoderState::Ready;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 转移到 Parsing 状态
|
||||
self.state = DecoderState::Parsing;
|
||||
|
||||
match parse_frame(&self.buffer) {
|
||||
Ok(Some((frame, consumed))) => {
|
||||
// 成功解析
|
||||
self.buffer.advance(consumed);
|
||||
self.state = DecoderState::Ready;
|
||||
self.frames_decoded += 1;
|
||||
self.error_count = 0; // 重置连续错误计数
|
||||
Ok(Some(frame))
|
||||
}
|
||||
Ok(None) => {
|
||||
// 数据不足,回到 Ready 状态等待更多数据
|
||||
self.state = DecoderState::Ready;
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_count += 1;
|
||||
let error_msg = e.to_string();
|
||||
|
||||
// 检查是否超过最大错误数
|
||||
if self.error_count >= self.max_errors {
|
||||
self.state = DecoderState::Stopped;
|
||||
tracing::error!(
|
||||
"解码器停止: 连续 {} 次错误,最后错误: {}",
|
||||
self.error_count,
|
||||
error_msg
|
||||
);
|
||||
return Err(ParseError::TooManyErrors {
|
||||
count: self.error_count,
|
||||
last_error: error_msg,
|
||||
});
|
||||
}
|
||||
|
||||
// 根据错误类型采用不同的恢复策略
|
||||
self.try_recover(&e);
|
||||
self.state = DecoderState::Recovering;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建解码迭代器
|
||||
pub fn decode_iter(&mut self) -> DecodeIter<'_> {
|
||||
DecodeIter { decoder: self }
|
||||
}
|
||||
|
||||
/// 尝试容错恢复
|
||||
///
|
||||
/// 根据错误类型采用不同的恢复策略(参考 kiro-kt 的设计):
|
||||
/// - Prelude 阶段错误(CRC 失败、长度异常):跳过 1 字节,尝试找下一帧边界
|
||||
/// - Data 阶段错误(Message CRC 失败、Header 解析失败):跳过整个损坏帧
|
||||
fn try_recover(&mut self, error: &ParseError) {
|
||||
if self.buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match error {
|
||||
// Prelude 阶段错误:可能是帧边界错位,逐字节扫描找下一个有效边界
|
||||
ParseError::PreludeCrcMismatch { .. }
|
||||
| ParseError::MessageTooSmall { .. }
|
||||
| ParseError::MessageTooLarge { .. } => {
|
||||
let skipped_byte = self.buffer[0];
|
||||
self.buffer.advance(1);
|
||||
self.bytes_skipped += 1;
|
||||
tracing::warn!(
|
||||
"Prelude 错误恢复: 跳过字节 0x{:02x} (累计跳过 {} 字节)",
|
||||
skipped_byte,
|
||||
self.bytes_skipped
|
||||
);
|
||||
}
|
||||
|
||||
// Data 阶段错误:帧边界正确但数据损坏,跳过整个帧
|
||||
ParseError::MessageCrcMismatch { .. } | ParseError::HeaderParseFailed(_) => {
|
||||
// 尝试读取 total_length 来跳过整帧
|
||||
if self.buffer.len() >= PRELUDE_SIZE {
|
||||
let total_length = u32::from_be_bytes([
|
||||
self.buffer[0],
|
||||
self.buffer[1],
|
||||
self.buffer[2],
|
||||
self.buffer[3],
|
||||
]) as usize;
|
||||
|
||||
// 确保 total_length 合理且缓冲区有足够数据
|
||||
if total_length >= 16 && total_length <= self.buffer.len() {
|
||||
tracing::warn!("Data 错误恢复: 跳过损坏帧 ({} 字节)", total_length);
|
||||
self.buffer.advance(total_length);
|
||||
self.bytes_skipped += total_length;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 无法确定帧长度,回退到逐字节跳过
|
||||
let skipped_byte = self.buffer[0];
|
||||
self.buffer.advance(1);
|
||||
self.bytes_skipped += 1;
|
||||
tracing::warn!(
|
||||
"Data 错误恢复 (回退): 跳过字节 0x{:02x} (累计跳过 {} 字节)",
|
||||
skipped_byte,
|
||||
self.bytes_skipped
|
||||
);
|
||||
}
|
||||
|
||||
// 其他错误:逐字节跳过
|
||||
_ => {
|
||||
let skipped_byte = self.buffer[0];
|
||||
self.buffer.advance(1);
|
||||
self.bytes_skipped += 1;
|
||||
tracing::warn!(
|
||||
"通用错误恢复: 跳过字节 0x{:02x} (累计跳过 {} 字节)",
|
||||
skipped_byte,
|
||||
self.bytes_skipped
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期管理方法 ====================
|
||||
|
||||
/// 重置解码器到初始状态
|
||||
///
|
||||
/// 清空缓冲区和所有计数器,恢复到 Ready 状态
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.state = DecoderState::Ready;
|
||||
self.frames_decoded = 0;
|
||||
self.error_count = 0;
|
||||
self.bytes_skipped = 0;
|
||||
}
|
||||
|
||||
/// 获取当前状态
|
||||
#[allow(dead_code)]
|
||||
pub fn state(&self) -> DecoderState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// 检查是否处于 Ready 状态
|
||||
#[allow(dead_code)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.state == DecoderState::Ready
|
||||
}
|
||||
|
||||
/// 检查是否处于 Stopped 状态
|
||||
#[allow(dead_code)]
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
self.state == DecoderState::Stopped
|
||||
}
|
||||
|
||||
/// 检查是否处于 Recovering 状态
|
||||
#[allow(dead_code)]
|
||||
pub fn is_recovering(&self) -> bool {
|
||||
self.state == DecoderState::Recovering
|
||||
}
|
||||
|
||||
/// 获取已解码的帧数量
|
||||
#[allow(dead_code)]
|
||||
pub fn frames_decoded(&self) -> usize {
|
||||
self.frames_decoded
|
||||
}
|
||||
|
||||
/// 获取当前连续错误计数
|
||||
#[allow(dead_code)]
|
||||
pub fn error_count(&self) -> usize {
|
||||
self.error_count
|
||||
}
|
||||
|
||||
/// 获取跳过的字节数
|
||||
#[allow(dead_code)]
|
||||
pub fn bytes_skipped(&self) -> usize {
|
||||
self.bytes_skipped
|
||||
}
|
||||
|
||||
/// 获取缓冲区中待处理的字节数
|
||||
#[allow(dead_code)]
|
||||
pub fn buffer_len(&self) -> usize {
|
||||
self.buffer.len()
|
||||
}
|
||||
|
||||
/// 尝试从 Stopped 状态恢复
|
||||
///
|
||||
/// 重置错误计数并转移到 Ready 状态
|
||||
/// 注意:缓冲区内容保留,可能仍包含损坏数据
|
||||
#[allow(dead_code)]
|
||||
pub fn try_resume(&mut self) {
|
||||
if self.state == DecoderState::Stopped {
|
||||
self.error_count = 0;
|
||||
self.state = DecoderState::Ready;
|
||||
tracing::info!("解码器从 Stopped 状态恢复");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解码迭代器
|
||||
pub struct DecodeIter<'a> {
|
||||
decoder: &'a mut EventStreamDecoder,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for DecodeIter<'a> {
|
||||
type Item = ParseResult<Frame>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// 如果处于 Stopped 或 Recovering 状态,停止迭代
|
||||
match self.decoder.state {
|
||||
DecoderState::Stopped => return None,
|
||||
DecoderState::Recovering => return None,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match self.decoder.decode() {
|
||||
Ok(Some(frame)) => Some(Ok(frame)),
|
||||
Ok(None) => None,
|
||||
Err(e) => Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_decoder_new() {
|
||||
let decoder = EventStreamDecoder::new();
|
||||
assert_eq!(decoder.state(), DecoderState::Ready);
|
||||
assert_eq!(decoder.frames_decoded(), 0);
|
||||
assert_eq!(decoder.error_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decoder_feed() {
|
||||
let mut decoder = EventStreamDecoder::new();
|
||||
assert!(decoder.feed(&[1, 2, 3, 4]).is_ok());
|
||||
assert_eq!(decoder.buffer_len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decoder_buffer_overflow() {
|
||||
let mut decoder = EventStreamDecoder::with_config(1024, 5, 100);
|
||||
let result = decoder.feed(&[0u8; 101]);
|
||||
assert!(matches!(result, Err(ParseError::BufferOverflow { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decoder_insufficient_data() {
|
||||
let mut decoder = EventStreamDecoder::new();
|
||||
decoder.feed(&[0u8; 10]).unwrap();
|
||||
|
||||
let result = decoder.decode();
|
||||
assert!(matches!(result, Ok(None)));
|
||||
assert_eq!(decoder.state(), DecoderState::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decoder_reset() {
|
||||
let mut decoder = EventStreamDecoder::new();
|
||||
decoder.feed(&[1, 2, 3, 4]).unwrap();
|
||||
|
||||
decoder.reset();
|
||||
assert_eq!(decoder.state(), DecoderState::Ready);
|
||||
assert_eq!(decoder.buffer_len(), 0);
|
||||
assert_eq!(decoder.frames_decoded(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decoder_state_transitions() {
|
||||
let decoder = EventStreamDecoder::new();
|
||||
|
||||
// 初始状态
|
||||
assert!(decoder.is_ready());
|
||||
assert!(!decoder.is_stopped());
|
||||
assert!(!decoder.is_recovering());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decoder_try_resume() {
|
||||
let mut decoder = EventStreamDecoder::new();
|
||||
|
||||
// 手动设置为 Stopped 状态进行测试
|
||||
decoder.state = DecoderState::Stopped;
|
||||
decoder.error_count = 5;
|
||||
|
||||
decoder.try_resume();
|
||||
assert!(decoder.is_ready());
|
||||
assert_eq!(decoder.error_count(), 0);
|
||||
}
|
||||
}
|
||||
94
src/kiro/parser/error.rs
Normal file
94
src/kiro/parser/error.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
//! AWS Event Stream 解析错误定义
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// 解析错误类型
|
||||
#[derive(Debug)]
|
||||
pub enum ParseError {
|
||||
/// 数据不足,需要更多字节
|
||||
Incomplete { needed: usize, available: usize },
|
||||
/// Prelude CRC 校验失败
|
||||
PreludeCrcMismatch { expected: u32, actual: u32 },
|
||||
/// Message CRC 校验失败
|
||||
MessageCrcMismatch { expected: u32, actual: u32 },
|
||||
/// 无效的头部值类型
|
||||
InvalidHeaderType(u8),
|
||||
/// 头部解析错误
|
||||
HeaderParseFailed(String),
|
||||
/// 消息长度超限
|
||||
MessageTooLarge { length: u32, max: u32 },
|
||||
/// 消息长度过小
|
||||
MessageTooSmall { length: u32, min: u32 },
|
||||
/// 无效的消息类型
|
||||
InvalidMessageType(String),
|
||||
/// Payload 反序列化失败
|
||||
PayloadDeserialize(serde_json::Error),
|
||||
/// IO 错误
|
||||
Io(std::io::Error),
|
||||
/// 连续错误过多,解码器已停止
|
||||
TooManyErrors { count: usize, last_error: String },
|
||||
/// 缓冲区溢出
|
||||
BufferOverflow { size: usize, max: usize },
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Incomplete { needed, available } => {
|
||||
write!(f, "数据不足: 需要 {} 字节, 当前 {} 字节", needed, available)
|
||||
}
|
||||
Self::PreludeCrcMismatch { expected, actual } => {
|
||||
write!(
|
||||
f,
|
||||
"Prelude CRC 校验失败: 期望 0x{:08x}, 实际 0x{:08x}",
|
||||
expected, actual
|
||||
)
|
||||
}
|
||||
Self::MessageCrcMismatch { expected, actual } => {
|
||||
write!(
|
||||
f,
|
||||
"Message CRC 校验失败: 期望 0x{:08x}, 实际 0x{:08x}",
|
||||
expected, actual
|
||||
)
|
||||
}
|
||||
Self::InvalidHeaderType(t) => write!(f, "无效的头部值类型: {}", t),
|
||||
Self::HeaderParseFailed(msg) => write!(f, "头部解析失败: {}", msg),
|
||||
Self::MessageTooLarge { length, max } => {
|
||||
write!(f, "消息长度超限: {} 字节 (最大 {})", length, max)
|
||||
}
|
||||
Self::MessageTooSmall { length, min } => {
|
||||
write!(f, "消息长度过小: {} 字节 (最小 {})", length, min)
|
||||
}
|
||||
Self::InvalidMessageType(t) => write!(f, "无效的消息类型: {}", t),
|
||||
Self::PayloadDeserialize(e) => write!(f, "Payload 反序列化失败: {}", e),
|
||||
Self::Io(e) => write!(f, "IO 错误: {}", e),
|
||||
Self::TooManyErrors { count, last_error } => {
|
||||
write!(
|
||||
f,
|
||||
"连续错误过多 ({} 次),解码器已停止: {}",
|
||||
count, last_error
|
||||
)
|
||||
}
|
||||
Self::BufferOverflow { size, max } => {
|
||||
write!(f, "缓冲区溢出: {} 字节 (最大 {})", size, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ParseError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ParseError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::PayloadDeserialize(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析结果类型
|
||||
pub type ParseResult<T> = Result<T, ParseError>;
|
||||
178
src/kiro/parser/frame.rs
Normal file
178
src/kiro/parser/frame.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
//! AWS Event Stream 消息帧解析
|
||||
//!
|
||||
//! ## 消息格式
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────────┬──────────────┬──────────────┬──────────┬──────────┬───────────┐
|
||||
//! │ Total Length │ Header Length│ Prelude CRC │ Headers │ Payload │ Msg CRC │
|
||||
//! │ (4 bytes) │ (4 bytes) │ (4 bytes) │ (变长) │ (变长) │ (4 bytes) │
|
||||
//! └──────────────┴──────────────┴──────────────┴──────────┴──────────┴───────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! - Total Length: 整个消息的总长度(包括自身 4 字节)
|
||||
//! - Header Length: 头部数据的长度
|
||||
//! - Prelude CRC: 前 8 字节(Total Length + Header Length)的 CRC32 校验
|
||||
//! - Headers: 头部数据
|
||||
//! - Payload: 载荷数据(通常是 JSON)
|
||||
//! - Message CRC: 整个消息(不含 Message CRC 自身)的 CRC32 校验
|
||||
|
||||
use super::crc::crc32;
|
||||
use super::error::{ParseError, ParseResult};
|
||||
use super::header::{Headers, parse_headers};
|
||||
|
||||
/// Prelude 固定大小 (12 字节)
|
||||
pub const PRELUDE_SIZE: usize = 12;
|
||||
|
||||
/// 最小消息大小 (Prelude + Message CRC)
|
||||
pub const MIN_MESSAGE_SIZE: usize = PRELUDE_SIZE + 4;
|
||||
|
||||
/// 最大消息大小限制 (16 MB)
|
||||
pub const MAX_MESSAGE_SIZE: u32 = 16 * 1024 * 1024;
|
||||
|
||||
/// 解析后的消息帧
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frame {
|
||||
/// 消息头部
|
||||
pub headers: Headers,
|
||||
/// 消息负载
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// 获取消息类型
|
||||
pub fn message_type(&self) -> Option<&str> {
|
||||
self.headers.message_type()
|
||||
}
|
||||
|
||||
/// 获取事件类型
|
||||
pub fn event_type(&self) -> Option<&str> {
|
||||
self.headers.event_type()
|
||||
}
|
||||
|
||||
/// 将 payload 解析为 JSON
|
||||
pub fn payload_as_json<T: serde::de::DeserializeOwned>(&self) -> ParseResult<T> {
|
||||
serde_json::from_slice(&self.payload).map_err(ParseError::PayloadDeserialize)
|
||||
}
|
||||
|
||||
/// 将 payload 解析为字符串
|
||||
pub fn payload_as_str(&self) -> String {
|
||||
String::from_utf8_lossy(&self.payload).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试从缓冲区解析一个完整的帧
|
||||
///
|
||||
/// 这是一个无状态的纯函数,每次调用独立解析。
|
||||
/// 缓冲区管理由上层 `EventStreamDecoder` 负责。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `buffer` - 输入缓冲区
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(Some((frame, consumed)))` - 成功解析,返回帧和消费的字节数
|
||||
/// - `Ok(None)` - 数据不足,需要更多数据
|
||||
/// - `Err(e)` - 解析错误
|
||||
pub fn parse_frame(buffer: &[u8]) -> ParseResult<Option<(Frame, usize)>> {
|
||||
// 检查是否有足够的数据读取 prelude
|
||||
if buffer.len() < PRELUDE_SIZE {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 读取 prelude
|
||||
let total_length = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
|
||||
let header_length = u32::from_be_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]);
|
||||
let prelude_crc = u32::from_be_bytes([buffer[8], buffer[9], buffer[10], buffer[11]]);
|
||||
|
||||
// 验证消息长度范围
|
||||
if total_length < MIN_MESSAGE_SIZE as u32 {
|
||||
return Err(ParseError::MessageTooSmall {
|
||||
length: total_length,
|
||||
min: MIN_MESSAGE_SIZE as u32,
|
||||
});
|
||||
}
|
||||
|
||||
if total_length > MAX_MESSAGE_SIZE {
|
||||
return Err(ParseError::MessageTooLarge {
|
||||
length: total_length,
|
||||
max: MAX_MESSAGE_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
let total_length = total_length as usize;
|
||||
let header_length = header_length as usize;
|
||||
|
||||
// 检查是否有完整的消息
|
||||
if buffer.len() < total_length {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 验证 Prelude CRC
|
||||
let actual_prelude_crc = crc32(&buffer[..8]);
|
||||
if actual_prelude_crc != prelude_crc {
|
||||
return Err(ParseError::PreludeCrcMismatch {
|
||||
expected: prelude_crc,
|
||||
actual: actual_prelude_crc,
|
||||
});
|
||||
}
|
||||
|
||||
// 读取 Message CRC
|
||||
let message_crc = u32::from_be_bytes([
|
||||
buffer[total_length - 4],
|
||||
buffer[total_length - 3],
|
||||
buffer[total_length - 2],
|
||||
buffer[total_length - 1],
|
||||
]);
|
||||
|
||||
// 验证 Message CRC (对整个消息不含最后4字节)
|
||||
let actual_message_crc = crc32(&buffer[..total_length - 4]);
|
||||
if actual_message_crc != message_crc {
|
||||
return Err(ParseError::MessageCrcMismatch {
|
||||
expected: message_crc,
|
||||
actual: actual_message_crc,
|
||||
});
|
||||
}
|
||||
|
||||
// 解析头部
|
||||
let headers_start = PRELUDE_SIZE;
|
||||
let headers_end = headers_start + header_length;
|
||||
|
||||
// 验证头部边界
|
||||
if headers_end > total_length - 4 {
|
||||
return Err(ParseError::HeaderParseFailed(
|
||||
"头部长度超出消息边界".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let headers = parse_headers(&buffer[headers_start..headers_end], header_length)?;
|
||||
|
||||
// 提取 payload (去除最后4字节的 message_crc)
|
||||
let payload_start = headers_end;
|
||||
let payload_end = total_length - 4;
|
||||
let payload = buffer[payload_start..payload_end].to_vec();
|
||||
|
||||
Ok(Some((Frame { headers, payload }, total_length)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_frame_insufficient_data() {
|
||||
let buffer = [0u8; 10]; // 小于 PRELUDE_SIZE
|
||||
assert!(matches!(parse_frame(&buffer), Ok(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frame_message_too_small() {
|
||||
// 构造一个 total_length = 10 的 prelude (小于最小值)
|
||||
let mut buffer = vec![0u8; 16];
|
||||
buffer[0..4].copy_from_slice(&10u32.to_be_bytes()); // total_length
|
||||
buffer[4..8].copy_from_slice(&0u32.to_be_bytes()); // header_length
|
||||
let prelude_crc = crc32(&buffer[0..8]);
|
||||
buffer[8..12].copy_from_slice(&prelude_crc.to_be_bytes());
|
||||
|
||||
let result = parse_frame(&buffer);
|
||||
assert!(matches!(result, Err(ParseError::MessageTooSmall { .. })));
|
||||
}
|
||||
}
|
||||
317
src/kiro/parser/header.rs
Normal file
317
src/kiro/parser/header.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
//! AWS Event Stream 头部解析
|
||||
//!
|
||||
//! 实现 AWS Event Stream 协议的头部解析功能
|
||||
|
||||
use super::error::{ParseError, ParseResult};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 头部值类型标识
|
||||
///
|
||||
/// AWS Event Stream 协议定义的 10 种值类型
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HeaderValueType {
|
||||
BoolTrue = 0,
|
||||
BoolFalse = 1,
|
||||
Byte = 2,
|
||||
Short = 3,
|
||||
Integer = 4,
|
||||
Long = 5,
|
||||
ByteArray = 6,
|
||||
String = 7,
|
||||
Timestamp = 8,
|
||||
Uuid = 9,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for HeaderValueType {
|
||||
type Error = ParseError;
|
||||
|
||||
fn try_from(value: u8) -> ParseResult<Self> {
|
||||
match value {
|
||||
0 => Ok(Self::BoolTrue),
|
||||
1 => Ok(Self::BoolFalse),
|
||||
2 => Ok(Self::Byte),
|
||||
3 => Ok(Self::Short),
|
||||
4 => Ok(Self::Integer),
|
||||
5 => Ok(Self::Long),
|
||||
6 => Ok(Self::ByteArray),
|
||||
7 => Ok(Self::String),
|
||||
8 => Ok(Self::Timestamp),
|
||||
9 => Ok(Self::Uuid),
|
||||
_ => Err(ParseError::InvalidHeaderType(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 头部值
|
||||
///
|
||||
/// 支持 AWS Event Stream 协议定义的所有值类型
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum HeaderValue {
|
||||
Bool(bool),
|
||||
Byte(i8),
|
||||
Short(i16),
|
||||
Integer(i32),
|
||||
Long(i64),
|
||||
ByteArray(Vec<u8>),
|
||||
String(String),
|
||||
Timestamp(i64),
|
||||
Uuid([u8; 16]),
|
||||
}
|
||||
|
||||
impl HeaderValue {
|
||||
/// 尝试获取字符串值
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::String(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息头部集合
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Headers {
|
||||
inner: HashMap<String, HeaderValue>,
|
||||
}
|
||||
|
||||
impl Headers {
|
||||
/// 创建空的头部集合
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 插入头部
|
||||
pub fn insert(&mut self, name: String, value: HeaderValue) {
|
||||
self.inner.insert(name, value);
|
||||
}
|
||||
|
||||
/// 获取头部值
|
||||
pub fn get(&self, name: &str) -> Option<&HeaderValue> {
|
||||
self.inner.get(name)
|
||||
}
|
||||
|
||||
/// 获取字符串类型的头部值
|
||||
pub fn get_string(&self, name: &str) -> Option<&str> {
|
||||
self.get(name).and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
/// 获取消息类型 (:message-type)
|
||||
pub fn message_type(&self) -> Option<&str> {
|
||||
self.get_string(":message-type")
|
||||
}
|
||||
|
||||
/// 获取事件类型 (:event-type)
|
||||
pub fn event_type(&self) -> Option<&str> {
|
||||
self.get_string(":event-type")
|
||||
}
|
||||
|
||||
/// 获取异常类型 (:exception-type)
|
||||
pub fn exception_type(&self) -> Option<&str> {
|
||||
self.get_string(":exception-type")
|
||||
}
|
||||
|
||||
/// 获取错误代码 (:error-code)
|
||||
pub fn error_code(&self) -> Option<&str> {
|
||||
self.get_string(":error-code")
|
||||
}
|
||||
}
|
||||
|
||||
/// 从字节流解析头部
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - 头部数据切片
|
||||
/// * `header_length` - 头部总长度
|
||||
///
|
||||
/// # Returns
|
||||
/// 解析后的 Headers 结构
|
||||
pub fn parse_headers(data: &[u8], header_length: usize) -> ParseResult<Headers> {
|
||||
// 验证数据长度是否足够
|
||||
if data.len() < header_length {
|
||||
return Err(ParseError::Incomplete {
|
||||
needed: header_length,
|
||||
available: data.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut headers = Headers::new();
|
||||
let mut offset = 0;
|
||||
|
||||
while offset < header_length {
|
||||
// 读取头部名称长度 (1 byte)
|
||||
if offset >= data.len() {
|
||||
break;
|
||||
}
|
||||
let name_len = data[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
// 验证名称长度
|
||||
if name_len == 0 {
|
||||
return Err(ParseError::HeaderParseFailed(
|
||||
"头部名称长度不能为 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 读取头部名称
|
||||
if offset + name_len > data.len() {
|
||||
return Err(ParseError::Incomplete {
|
||||
needed: name_len,
|
||||
available: data.len() - offset,
|
||||
});
|
||||
}
|
||||
let name = String::from_utf8_lossy(&data[offset..offset + name_len]).to_string();
|
||||
offset += name_len;
|
||||
|
||||
// 读取值类型 (1 byte)
|
||||
if offset >= data.len() {
|
||||
return Err(ParseError::Incomplete {
|
||||
needed: 1,
|
||||
available: 0,
|
||||
});
|
||||
}
|
||||
let value_type = HeaderValueType::try_from(data[offset])?;
|
||||
offset += 1;
|
||||
|
||||
// 根据类型解析值
|
||||
let value = parse_header_value(&data[offset..], value_type, &mut offset)?;
|
||||
headers.insert(name, value);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
/// 解析头部值
|
||||
fn parse_header_value(
|
||||
data: &[u8],
|
||||
value_type: HeaderValueType,
|
||||
global_offset: &mut usize,
|
||||
) -> ParseResult<HeaderValue> {
|
||||
let mut local_offset = 0;
|
||||
|
||||
let result = match value_type {
|
||||
HeaderValueType::BoolTrue => Ok(HeaderValue::Bool(true)),
|
||||
HeaderValueType::BoolFalse => Ok(HeaderValue::Bool(false)),
|
||||
HeaderValueType::Byte => {
|
||||
ensure_bytes(data, 1)?;
|
||||
let v = data[0] as i8;
|
||||
local_offset = 1;
|
||||
Ok(HeaderValue::Byte(v))
|
||||
}
|
||||
HeaderValueType::Short => {
|
||||
ensure_bytes(data, 2)?;
|
||||
let v = i16::from_be_bytes([data[0], data[1]]);
|
||||
local_offset = 2;
|
||||
Ok(HeaderValue::Short(v))
|
||||
}
|
||||
HeaderValueType::Integer => {
|
||||
ensure_bytes(data, 4)?;
|
||||
let v = i32::from_be_bytes([data[0], data[1], data[2], data[3]]);
|
||||
local_offset = 4;
|
||||
Ok(HeaderValue::Integer(v))
|
||||
}
|
||||
HeaderValueType::Long => {
|
||||
ensure_bytes(data, 8)?;
|
||||
let v = i64::from_be_bytes([
|
||||
data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
|
||||
]);
|
||||
local_offset = 8;
|
||||
Ok(HeaderValue::Long(v))
|
||||
}
|
||||
HeaderValueType::Timestamp => {
|
||||
ensure_bytes(data, 8)?;
|
||||
let v = i64::from_be_bytes([
|
||||
data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
|
||||
]);
|
||||
local_offset = 8;
|
||||
Ok(HeaderValue::Timestamp(v))
|
||||
}
|
||||
HeaderValueType::ByteArray => {
|
||||
ensure_bytes(data, 2)?;
|
||||
let len = u16::from_be_bytes([data[0], data[1]]) as usize;
|
||||
ensure_bytes(data, 2 + len)?;
|
||||
let v = data[2..2 + len].to_vec();
|
||||
local_offset = 2 + len;
|
||||
Ok(HeaderValue::ByteArray(v))
|
||||
}
|
||||
HeaderValueType::String => {
|
||||
ensure_bytes(data, 2)?;
|
||||
let len = u16::from_be_bytes([data[0], data[1]]) as usize;
|
||||
ensure_bytes(data, 2 + len)?;
|
||||
let v = String::from_utf8_lossy(&data[2..2 + len]).to_string();
|
||||
local_offset = 2 + len;
|
||||
Ok(HeaderValue::String(v))
|
||||
}
|
||||
HeaderValueType::Uuid => {
|
||||
ensure_bytes(data, 16)?;
|
||||
let mut uuid = [0u8; 16];
|
||||
uuid.copy_from_slice(&data[..16]);
|
||||
local_offset = 16;
|
||||
Ok(HeaderValue::Uuid(uuid))
|
||||
}
|
||||
};
|
||||
|
||||
*global_offset += local_offset;
|
||||
result
|
||||
}
|
||||
|
||||
/// 确保有足够的字节
|
||||
fn ensure_bytes(data: &[u8], needed: usize) -> ParseResult<()> {
|
||||
if data.len() < needed {
|
||||
Err(ParseError::Incomplete {
|
||||
needed,
|
||||
available: data.len(),
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_header_value_type_conversion() {
|
||||
assert_eq!(
|
||||
HeaderValueType::try_from(0).unwrap(),
|
||||
HeaderValueType::BoolTrue
|
||||
);
|
||||
assert_eq!(
|
||||
HeaderValueType::try_from(7).unwrap(),
|
||||
HeaderValueType::String
|
||||
);
|
||||
assert!(HeaderValueType::try_from(10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_value_as_str() {
|
||||
let value = HeaderValue::String("test".to_string());
|
||||
assert_eq!(value.as_str(), Some("test"));
|
||||
|
||||
let value = HeaderValue::Bool(true);
|
||||
assert_eq!(value.as_str(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_headers_get_string() {
|
||||
let mut headers = Headers::new();
|
||||
headers.insert(
|
||||
":message-type".to_string(),
|
||||
HeaderValue::String("event".to_string()),
|
||||
);
|
||||
assert_eq!(headers.message_type(), Some("event"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_headers_string() {
|
||||
// 构造一个简单的头部: name_len(1) + name + type(7=string) + value_len(2) + value
|
||||
// 头部名: "x" (长度 1)
|
||||
// 值类型: 7 (String)
|
||||
// 值: "ab" (长度 2)
|
||||
let data = [1u8, b'x', 7, 0, 2, b'a', b'b'];
|
||||
let headers = parse_headers(&data, data.len()).unwrap();
|
||||
assert_eq!(headers.get_string("x"), Some("ab"));
|
||||
}
|
||||
}
|
||||
10
src/kiro/parser/mod.rs
Normal file
10
src/kiro/parser/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! AWS Event Stream 解析器
|
||||
//!
|
||||
//! 提供对 AWS Event Stream 协议的解析支持,
|
||||
//! 用于处理 generateAssistantResponse 端点的流式响应
|
||||
|
||||
pub mod crc;
|
||||
pub mod decoder;
|
||||
pub mod error;
|
||||
pub mod frame;
|
||||
pub mod header;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user