1041006580 commited on
Commit
ddae732
·
1 Parent(s): 9843e1b

chore: 清理HuggingFace Space仓库,只保留部署必需文件

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +0 -54
  2. .github/workflows/build.yaml +0 -85
  3. .github/workflows/docker-build.yaml +0 -87
  4. .github/workflows/docker-build.yml +0 -55
  5. .gitignore +0 -12
  6. Cargo.toml +0 -34
  7. Dockerfile +0 -42
  8. admin-ui/index.html +0 -13
  9. admin-ui/package.json +0 -37
  10. admin-ui/postcss.config.js +0 -6
  11. admin-ui/public/vite.svg +0 -1
  12. admin-ui/src/App.tsx +0 -37
  13. admin-ui/src/api/credentials.ts +0 -86
  14. admin-ui/src/components/add-credential-dialog.tsx +0 -186
  15. admin-ui/src/components/balance-dialog.tsx +0 -104
  16. admin-ui/src/components/credential-card.tsx +0 -298
  17. admin-ui/src/components/dashboard.tsx +0 -186
  18. admin-ui/src/components/login-page.tsx +0 -62
  19. admin-ui/src/components/ui/badge.tsx +0 -39
  20. admin-ui/src/components/ui/button.tsx +0 -55
  21. admin-ui/src/components/ui/card.tsx +0 -78
  22. admin-ui/src/components/ui/dialog.tsx +0 -119
  23. admin-ui/src/components/ui/input.tsx +0 -24
  24. admin-ui/src/components/ui/progress.tsx +0 -35
  25. admin-ui/src/components/ui/sonner.tsx +0 -25
  26. admin-ui/src/components/ui/switch.tsx +0 -26
  27. admin-ui/src/hooks/use-credentials.ts +0 -87
  28. admin-ui/src/index.css +0 -60
  29. admin-ui/src/lib/storage.ts +0 -7
  30. admin-ui/src/lib/utils.ts +0 -106
  31. admin-ui/src/main.tsx +0 -22
  32. admin-ui/src/types/api.ts +0 -69
  33. admin-ui/tailwind.config.js +0 -53
  34. admin-ui/tsconfig.json +0 -24
  35. admin-ui/vite.config.ts +0 -25
  36. config.example.json +0 -7
  37. credentials.example.idc.json +0 -9
  38. credentials.example.multiple.json +0 -19
  39. credentials.example.social.json +0 -6
  40. entrypoint.sh +0 -61
  41. src/admin/error.rs +0 -64
  42. src/admin/handlers.rs +0 -104
  43. src/admin/middleware.rs +0 -50
  44. src/admin/mod.rs +0 -28
  45. src/admin/router.rs +0 -47
  46. src/admin/service.rs +0 -234
  47. src/admin/types.rs +0 -187
  48. src/admin_ui/mod.rs +0 -7
  49. src/admin_ui/router.rs +0 -109
  50. src/anthropic/converter.rs +0 -1118
.dockerignore DELETED
@@ -1,54 +0,0 @@
1
- # Rust build artifacts
2
- target/
3
- Cargo.lock
4
-
5
- # Node.js dependencies and build artifacts
6
- admin-ui/node_modules/
7
- admin-ui/dist/
8
- admin-ui/pnpm-lock.yaml
9
- admin-ui/tsconfig.tsbuildinfo
10
- admin-ui/.vite/
11
-
12
- # Version control
13
- .git/
14
- .gitignore
15
-
16
- # IDE and editor files
17
- .idea/
18
- .vscode/
19
- *.swp
20
- *.swo
21
- *~
22
-
23
- # Claude/AI
24
- .claude/
25
- CLAUDE.md
26
- AGENTS.md
27
-
28
- # CI/CD
29
- .github/
30
-
31
- # Documentation and examples
32
- *.md
33
- README.md
34
- config.example.json
35
- credentials.example.*.json
36
-
37
- # Development and test files
38
- src/test.rs
39
- src/debug.rs
40
- test.json
41
- tools/
42
-
43
- # OS-specific files
44
- .DS_Store
45
- Thumbs.db
46
-
47
- # Local configuration (keep templates only)
48
- config.json
49
- credentials.json
50
-
51
- # Docker files
52
- Dockerfile
53
- .dockerignore
54
- docker-compose*.yml
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/build.yaml DELETED
@@ -1,85 +0,0 @@
1
- name: Build Artifacts
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
- workflow_dispatch:
8
- inputs:
9
- version:
10
- description: 'Version label for artifacts (e.g., 2025.12.1)'
11
- required: true
12
- default: '2026.1.1'
13
-
14
- permissions:
15
- contents: read
16
-
17
- jobs:
18
- build:
19
- strategy:
20
- fail-fast: false
21
- matrix:
22
- include:
23
- - platform: macos-latest
24
- target: aarch64-apple-darwin
25
- name: macOS-arm64
26
- - platform: macos-latest
27
- target: x86_64-apple-darwin
28
- name: macOS-x64
29
- - platform: windows-latest
30
- target: x86_64-pc-windows-msvc
31
- name: Windows-x64
32
- - platform: ubuntu-22.04
33
- target: x86_64-unknown-linux-gnu
34
- name: Linux-x64
35
- - platform: ubuntu-22.04-arm
36
- target: aarch64-unknown-linux-gnu
37
- name: Linux-arm64
38
-
39
- runs-on: ${{ matrix.platform }}
40
-
41
- steps:
42
- - name: Checkout
43
- uses: actions/checkout@v4
44
-
45
- - name: Setup Node.js
46
- uses: actions/setup-node@v4
47
- with:
48
- node-version: '20'
49
-
50
- - name: Setup pnpm
51
- uses: pnpm/action-setup@v4
52
- with:
53
- version: 9
54
-
55
- - name: Install admin-ui dependencies
56
- working-directory: admin-ui
57
- run: pnpm install
58
-
59
- - name: Build admin-ui
60
- working-directory: admin-ui
61
- run: pnpm build
62
-
63
- - name: Setup Rust
64
- uses: dtolnay/rust-toolchain@stable
65
- with:
66
- targets: ${{ matrix.target }}
67
-
68
- - name: Setup Rust cache
69
- uses: Swatinem/rust-cache@v2
70
- with:
71
- shared-key: "rust-cache-${{ matrix.target }}"
72
- cache-on-failure: true
73
-
74
- - name: Build app
75
- run: cargo build --release --target ${{ matrix.target }}
76
-
77
- - name: Upload build artifacts
78
- uses: actions/upload-artifact@v4
79
- with:
80
- name: kiro-rs-${{ github.event.inputs.version || github.ref_name }}-${{ matrix.name }}
81
- if-no-files-found: error
82
- compression-level: 6
83
- path: |
84
- target/${{ matrix.target }}/release/kiro-rs
85
- target/${{ matrix.target }}/release/kiro-rs.exe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/docker-build.yaml DELETED
@@ -1,87 +0,0 @@
1
- name: Build and Push Docker Images
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
- workflow_dispatch:
8
- inputs:
9
- version:
10
- description: 'Version tag for Docker images (e.g., 2025.12.1)'
11
- required: true
12
- default: '2026.1.1'
13
-
14
- permissions:
15
- contents: read
16
- packages: write
17
-
18
- jobs:
19
- build:
20
- runs-on: ${{ matrix.runner }}
21
-
22
- strategy:
23
- fail-fast: false
24
- matrix:
25
- include:
26
- - platform: linux/amd64
27
- runner: ubuntu-latest
28
- arch: amd64
29
- - platform: linux/arm64
30
- runner: ubuntu-22.04-arm
31
- arch: arm64
32
-
33
- steps:
34
- - name: Checkout
35
- uses: actions/checkout@v4
36
-
37
- - name: Set up Docker Buildx
38
- uses: docker/setup-buildx-action@v3
39
-
40
- - name: Log in to GitHub Container Registry
41
- uses: docker/login-action@v3
42
- with:
43
- registry: ghcr.io
44
- username: ${{ github.repository_owner }}
45
- password: ${{ github.token }}
46
-
47
- - name: Build and push
48
- uses: docker/build-push-action@v6
49
- with:
50
- context: .
51
- platforms: ${{ matrix.platform }}
52
- cache-from: type=gha
53
- cache-to: type=gha,mode=max
54
- push: true
55
- provenance: false
56
- tags: ghcr.io/${{ github.repository_owner }}/kiro-rs:${{ github.event.inputs.version || github.ref_name }}-${{ matrix.arch }}
57
- labels: |
58
- org.opencontainers.image.source=https://github.com/${{ github.repository }}
59
- org.opencontainers.image.description=Kiro.rs Docker Image
60
-
61
- manifest:
62
- needs: build
63
- runs-on: ubuntu-latest
64
- steps:
65
- - name: Log in to GitHub Container Registry
66
- uses: docker/login-action@v3
67
- with:
68
- registry: ghcr.io
69
- username: ${{ github.repository_owner }}
70
- password: ${{ github.token }}
71
-
72
- - name: Create and push multi-arch manifest
73
- run: |
74
- VERSION="${{ github.event.inputs.version || github.ref_name }}"
75
- IMAGE="ghcr.io/${{ github.repository_owner }}/kiro-rs"
76
-
77
- # Create manifest for version tag
78
- docker manifest create ${IMAGE}:${VERSION} \
79
- ${IMAGE}:${VERSION}-amd64 \
80
- ${IMAGE}:${VERSION}-arm64
81
- docker manifest push ${IMAGE}:${VERSION}
82
-
83
- # Create manifest for latest tag
84
- docker manifest create ${IMAGE}:latest \
85
- ${IMAGE}:${VERSION}-amd64 \
86
- ${IMAGE}:${VERSION}-arm64
87
- docker manifest push ${IMAGE}:latest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/docker-build.yml DELETED
@@ -1,55 +0,0 @@
1
- name: Build and Push Docker Image
2
-
3
- on:
4
- push:
5
- branches:
6
- - master
7
- paths-ignore:
8
- - '**.md'
9
- - '.gitignore'
10
- workflow_dispatch:
11
-
12
- env:
13
- REGISTRY: ghcr.io
14
- IMAGE_NAME: ${{ github.repository }}
15
-
16
- jobs:
17
- build-and-push:
18
- runs-on: ubuntu-latest
19
- permissions:
20
- contents: read
21
- packages: write
22
-
23
- steps:
24
- - name: Checkout repository
25
- uses: actions/checkout@v4
26
-
27
- - name: Set up Docker Buildx
28
- uses: docker/setup-buildx-action@v3
29
-
30
- - name: Log in to GitHub Container Registry
31
- uses: docker/login-action@v3
32
- with:
33
- registry: ${{ env.REGISTRY }}
34
- username: ${{ github.actor }}
35
- password: ${{ secrets.GITHUB_TOKEN }}
36
-
37
- - name: Extract metadata
38
- id: meta
39
- uses: docker/metadata-action@v5
40
- with:
41
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
42
- tags: |
43
- type=raw,value=latest,enable={{is_default_branch}}
44
- type=sha,prefix={{branch}}-
45
-
46
- - name: Build and push Docker image
47
- uses: docker/build-push-action@v5
48
- with:
49
- context: .
50
- push: true
51
- tags: ${{ steps.meta.outputs.tags }}
52
- labels: ${{ steps.meta.outputs.labels }}
53
- cache-from: type=gha
54
- cache-to: type=gha,mode=max
55
- platforms: linux/amd64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore DELETED
@@ -1,12 +0,0 @@
1
- /target
2
- /CLAUDE.md
3
- /AGENTS.md
4
- /config.json
5
- /credentials.json
6
- /.idea
7
- /test.json
8
- /Cargo.lock
9
- /admin-ui/node_modules/
10
- /admin-ui/dist/
11
- /admin-ui/pnpm-lock.yaml
12
- /admin-ui/tsconfig.tsbuildinfo
 
 
 
 
 
 
 
 
 
 
 
 
 
Cargo.toml DELETED
@@ -1,34 +0,0 @@
1
- [package]
2
- name = "kiro-rs"
3
- version = "2026.1.5"
4
- edition = "2024"
5
-
6
- [profile.release]
7
- lto = true
8
- strip = true
9
-
10
- [dependencies]
11
- axum = "0.8"
12
- tokio = { version = "1.0", features = ["full"] }
13
- reqwest = { version = "0.12", features = ["stream", "json", "socks"] }
14
- serde = { version = "1.0", features = ["derive"] }
15
- serde_json = "1.0"
16
- tracing = "0.1"
17
- tracing-subscriber = { version = "0.3", features = ["env-filter"] }
18
- anyhow = "1.0"
19
- http = "1.0"
20
- futures = "0.3"
21
- chrono = { version = "0.4", features = ["serde"] }
22
- uuid = { version = "1.10", features = ["v1", "v4", "fast-rng"] }
23
- fastrand = "2"
24
- sha2 = "0.10"
25
- hex = "0.4"
26
- crc = "3" # CRC32C 计算
27
- bytes = "1" # 高效的字节缓冲区
28
- tower-http = { version = "0.6", features = ["cors"] }
29
- clap = { version = "4.5", features = ["derive"] }
30
- urlencoding = "2"
31
- parking_lot = "0.12" # 高性能同步原语
32
- subtle = "2.6" # 常量时间比较(防止时序攻击)
33
- rust-embed = "8" # 嵌入静态文件
34
- mime_guess = "2" # MIME 类型推断
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile DELETED
@@ -1,42 +0,0 @@
1
- FROM node:22-alpine AS frontend-builder
2
-
3
- WORKDIR /app/admin-ui
4
- COPY admin-ui/package.json ./
5
- RUN npm install -g pnpm && pnpm install
6
- COPY admin-ui ./
7
- RUN pnpm build
8
-
9
- FROM rust:1.85-alpine AS builder
10
-
11
- RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
12
-
13
- WORKDIR /app
14
- COPY Cargo.toml Cargo.lock* ./
15
- COPY src ./src
16
- COPY --from=frontend-builder /app/admin-ui/dist /app/admin-ui/dist
17
-
18
- RUN cargo build --release
19
-
20
- FROM alpine:3.21
21
-
22
- RUN apk add --no-cache ca-certificates
23
-
24
- # 创建非 root 用户 (HuggingFace Spaces 要求)
25
- RUN adduser -D -u 1000 appuser
26
-
27
- WORKDIR /app
28
-
29
- COPY --from=builder /app/target/release/kiro-rs /app/kiro-rs
30
- COPY entrypoint.sh /app/entrypoint.sh
31
-
32
- # 创建配置目录并设置权限
33
- RUN mkdir -p /app/config && \
34
- chown -R appuser:appuser /app && \
35
- chmod +x /app/entrypoint.sh
36
-
37
- USER appuser
38
-
39
- # HuggingFace Spaces 只支持端口 7860
40
- EXPOSE 7860
41
-
42
- ENTRYPOINT ["/app/entrypoint.sh"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/index.html DELETED
@@ -1,13 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>Kiro Admin</title>
8
- </head>
9
- <body>
10
- <div id="root"></div>
11
- <script type="module" src="/src/main.tsx"></script>
12
- </body>
13
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/package.json DELETED
@@ -1,37 +0,0 @@
1
- {
2
- "name": "kiro-admin-ui",
3
- "version": "1.0.0",
4
- "type": "module",
5
- "scripts": {
6
- "dev": "vite",
7
- "build": "tsc -b && vite build",
8
- "preview": "vite preview"
9
- },
10
- "dependencies": {
11
- "react": "^18.3.1",
12
- "react-dom": "^18.3.1",
13
- "@tanstack/react-query": "^5.60.0",
14
- "axios": "^1.7.0",
15
- "clsx": "^2.1.1",
16
- "tailwind-merge": "^2.5.0",
17
- "class-variance-authority": "^0.7.0",
18
- "@radix-ui/react-slot": "^1.1.0",
19
- "@radix-ui/react-switch": "^1.1.0",
20
- "@radix-ui/react-dialog": "^1.1.0",
21
- "@radix-ui/react-dropdown-menu": "^2.1.0",
22
- "@radix-ui/react-toast": "^1.2.0",
23
- "@radix-ui/react-tooltip": "^1.1.0",
24
- "lucide-react": "^0.460.0",
25
- "sonner": "^1.7.0"
26
- },
27
- "devDependencies": {
28
- "@types/react": "^18.3.12",
29
- "@types/react-dom": "^18.3.1",
30
- "@vitejs/plugin-react-swc": "^3.7.0",
31
- "autoprefixer": "^10.4.20",
32
- "postcss": "^8.4.47",
33
- "tailwindcss": "^3.4.14",
34
- "typescript": "^5.6.3",
35
- "vite": "^5.4.0"
36
- }
37
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/postcss.config.js DELETED
@@ -1,6 +0,0 @@
1
- export default {
2
- plugins: {
3
- tailwindcss: {},
4
- autoprefixer: {},
5
- },
6
- }
 
 
 
 
 
 
 
admin-ui/public/vite.svg DELETED
admin-ui/src/App.tsx DELETED
@@ -1,37 +0,0 @@
1
- import { useState, useEffect } from 'react'
2
- import { storage } from '@/lib/storage'
3
- import { LoginPage } from '@/components/login-page'
4
- import { Dashboard } from '@/components/dashboard'
5
- import { Toaster } from '@/components/ui/sonner'
6
-
7
- function App() {
8
- const [isLoggedIn, setIsLoggedIn] = useState(false)
9
-
10
- useEffect(() => {
11
- // 检查是否已经有保存的 API Key
12
- if (storage.getApiKey()) {
13
- setIsLoggedIn(true)
14
- }
15
- }, [])
16
-
17
- const handleLogin = () => {
18
- setIsLoggedIn(true)
19
- }
20
-
21
- const handleLogout = () => {
22
- setIsLoggedIn(false)
23
- }
24
-
25
- return (
26
- <>
27
- {isLoggedIn ? (
28
- <Dashboard onLogout={handleLogout} />
29
- ) : (
30
- <LoginPage onLogin={handleLogin} />
31
- )}
32
- <Toaster position="top-right" />
33
- </>
34
- )
35
- }
36
-
37
- export default App
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/api/credentials.ts DELETED
@@ -1,86 +0,0 @@
1
- import axios from 'axios'
2
- import { storage } from '@/lib/storage'
3
- import type {
4
- CredentialsStatusResponse,
5
- BalanceResponse,
6
- SuccessResponse,
7
- SetDisabledRequest,
8
- SetPriorityRequest,
9
- AddCredentialRequest,
10
- AddCredentialResponse,
11
- } from '@/types/api'
12
-
13
- // 创建 axios 实例
14
- const api = axios.create({
15
- baseURL: '/api/admin',
16
- headers: {
17
- 'Content-Type': 'application/json',
18
- },
19
- })
20
-
21
- // 请求拦截器添加 API Key
22
- api.interceptors.request.use((config) => {
23
- const apiKey = storage.getApiKey()
24
- if (apiKey) {
25
- config.headers['x-api-key'] = apiKey
26
- }
27
- return config
28
- })
29
-
30
- // 获取所有凭据状态
31
- export async function getCredentials(): Promise<CredentialsStatusResponse> {
32
- const { data } = await api.get<CredentialsStatusResponse>('/credentials')
33
- return data
34
- }
35
-
36
- // 设置凭据禁用状态
37
- export async function setCredentialDisabled(
38
- id: number,
39
- disabled: boolean
40
- ): Promise<SuccessResponse> {
41
- const { data } = await api.post<SuccessResponse>(
42
- `/credentials/${id}/disabled`,
43
- { disabled } as SetDisabledRequest
44
- )
45
- return data
46
- }
47
-
48
- // 设置凭据优先级
49
- export async function setCredentialPriority(
50
- id: number,
51
- priority: number
52
- ): Promise<SuccessResponse> {
53
- const { data } = await api.post<SuccessResponse>(
54
- `/credentials/${id}/priority`,
55
- { priority } as SetPriorityRequest
56
- )
57
- return data
58
- }
59
-
60
- // 重置失败计数
61
- export async function resetCredentialFailure(
62
- id: number
63
- ): Promise<SuccessResponse> {
64
- const { data } = await api.post<SuccessResponse>(`/credentials/${id}/reset`)
65
- return data
66
- }
67
-
68
- // 获取凭据余额
69
- export async function getCredentialBalance(id: number): Promise<BalanceResponse> {
70
- const { data } = await api.get<BalanceResponse>(`/credentials/${id}/balance`)
71
- return data
72
- }
73
-
74
- // 添加新凭据
75
- export async function addCredential(
76
- req: AddCredentialRequest
77
- ): Promise<AddCredentialResponse> {
78
- const { data } = await api.post<AddCredentialResponse>('/credentials', req)
79
- return data
80
- }
81
-
82
- // 删除凭据
83
- export async function deleteCredential(id: number): Promise<SuccessResponse> {
84
- const { data } = await api.delete<SuccessResponse>(`/credentials/${id}`)
85
- return data
86
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/add-credential-dialog.tsx DELETED
@@ -1,186 +0,0 @@
1
- import { useState } from 'react'
2
- import { toast } from 'sonner'
3
- import {
4
- Dialog,
5
- DialogContent,
6
- DialogHeader,
7
- DialogTitle,
8
- DialogFooter,
9
- } from '@/components/ui/dialog'
10
- import { Button } from '@/components/ui/button'
11
- import { Input } from '@/components/ui/input'
12
- import { useAddCredential } from '@/hooks/use-credentials'
13
- import { extractErrorMessage } from '@/lib/utils'
14
-
15
- interface AddCredentialDialogProps {
16
- open: boolean
17
- onOpenChange: (open: boolean) => void
18
- }
19
-
20
- type AuthMethod = 'social' | 'idc' | 'builder-id'
21
-
22
- export function AddCredentialDialog({ open, onOpenChange }: AddCredentialDialogProps) {
23
- const [refreshToken, setRefreshToken] = useState('')
24
- const [authMethod, setAuthMethod] = useState<AuthMethod>('social')
25
- const [clientId, setClientId] = useState('')
26
- const [clientSecret, setClientSecret] = useState('')
27
- const [priority, setPriority] = useState('0')
28
-
29
- const { mutate, isPending } = useAddCredential()
30
-
31
- const resetForm = () => {
32
- setRefreshToken('')
33
- setAuthMethod('social')
34
- setClientId('')
35
- setClientSecret('')
36
- setPriority('0')
37
- }
38
-
39
- const handleSubmit = (e: React.FormEvent) => {
40
- e.preventDefault()
41
-
42
- // 验证必填字段
43
- if (!refreshToken.trim()) {
44
- toast.error('请输入 Refresh Token')
45
- return
46
- }
47
-
48
- // IdC/Builder-ID 需要额外字段
49
- if ((authMethod === 'idc' || authMethod === 'builder-id') &&
50
- (!clientId.trim() || !clientSecret.trim())) {
51
- toast.error('IdC/Builder-ID 认证需要填写 Client ID 和 Client Secret')
52
- return
53
- }
54
-
55
- mutate(
56
- {
57
- refreshToken: refreshToken.trim(),
58
- authMethod,
59
- clientId: clientId.trim() || undefined,
60
- clientSecret: clientSecret.trim() || undefined,
61
- priority: parseInt(priority) || 0,
62
- },
63
- {
64
- onSuccess: (data) => {
65
- toast.success(data.message)
66
- onOpenChange(false)
67
- resetForm()
68
- },
69
- onError: (error: unknown) => {
70
- toast.error(`添加失败: ${extractErrorMessage(error)}`)
71
- },
72
- }
73
- )
74
- }
75
-
76
- return (
77
- <Dialog open={open} onOpenChange={onOpenChange}>
78
- <DialogContent className="sm:max-w-lg">
79
- <DialogHeader>
80
- <DialogTitle>添加凭据</DialogTitle>
81
- </DialogHeader>
82
-
83
- <form onSubmit={handleSubmit}>
84
- <div className="space-y-4 py-4">
85
- {/* Refresh Token */}
86
- <div className="space-y-2">
87
- <label htmlFor="refreshToken" className="text-sm font-medium">
88
- Refresh Token <span className="text-red-500">*</span>
89
- </label>
90
- <Input
91
- id="refreshToken"
92
- type="password"
93
- placeholder="请输入 Refresh Token"
94
- value={refreshToken}
95
- onChange={(e) => setRefreshToken(e.target.value)}
96
- disabled={isPending}
97
- />
98
- </div>
99
-
100
- {/* 认证方式 */}
101
- <div className="space-y-2">
102
- <label htmlFor="authMethod" className="text-sm font-medium">
103
- 认证方式
104
- </label>
105
- <select
106
- id="authMethod"
107
- value={authMethod}
108
- onChange={(e) => setAuthMethod(e.target.value as AuthMethod)}
109
- disabled={isPending}
110
- 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"
111
- >
112
- <option value="social">Social</option>
113
- <option value="idc">IdC</option>
114
- <option value="builder-id">Builder-ID</option>
115
- </select>
116
- </div>
117
-
118
- {/* IdC/Builder-ID 额外字段 */}
119
- {(authMethod === 'idc' || authMethod === 'builder-id') && (
120
- <>
121
- <div className="space-y-2">
122
- <label htmlFor="clientId" className="text-sm font-medium">
123
- Client ID <span className="text-red-500">*</span>
124
- </label>
125
- <Input
126
- id="clientId"
127
- placeholder="请输入 Client ID"
128
- value={clientId}
129
- onChange={(e) => setClientId(e.target.value)}
130
- disabled={isPending}
131
- />
132
- </div>
133
- <div className="space-y-2">
134
- <label htmlFor="clientSecret" className="text-sm font-medium">
135
- Client Secret <span className="text-red-500">*</span>
136
- </label>
137
- <Input
138
- id="clientSecret"
139
- type="password"
140
- placeholder="请输入 Client Secret"
141
- value={clientSecret}
142
- onChange={(e) => setClientSecret(e.target.value)}
143
- disabled={isPending}
144
- />
145
- </div>
146
- </>
147
- )}
148
-
149
- {/* 优先级 */}
150
- <div className="space-y-2">
151
- <label htmlFor="priority" className="text-sm font-medium">
152
- 优先级
153
- </label>
154
- <Input
155
- id="priority"
156
- type="number"
157
- min="0"
158
- placeholder="数字越小优先级越高"
159
- value={priority}
160
- onChange={(e) => setPriority(e.target.value)}
161
- disabled={isPending}
162
- />
163
- <p className="text-xs text-muted-foreground">
164
- 数字越小优先级越高,默认为 0
165
- </p>
166
- </div>
167
- </div>
168
-
169
- <DialogFooter>
170
- <Button
171
- type="button"
172
- variant="outline"
173
- onClick={() => onOpenChange(false)}
174
- disabled={isPending}
175
- >
176
- 取消
177
- </Button>
178
- <Button type="submit" disabled={isPending}>
179
- {isPending ? '添加中...' : '添加'}
180
- </Button>
181
- </DialogFooter>
182
- </form>
183
- </DialogContent>
184
- </Dialog>
185
- )
186
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/balance-dialog.tsx DELETED
@@ -1,104 +0,0 @@
1
- import {
2
- Dialog,
3
- DialogContent,
4
- DialogHeader,
5
- DialogTitle,
6
- } from '@/components/ui/dialog'
7
- import { Progress } from '@/components/ui/progress'
8
- import { useCredentialBalance } from '@/hooks/use-credentials'
9
- import { parseError } from '@/lib/utils'
10
-
11
- interface BalanceDialogProps {
12
- credentialId: number | null
13
- open: boolean
14
- onOpenChange: (open: boolean) => void
15
- }
16
-
17
- export function BalanceDialog({ credentialId, open, onOpenChange }: BalanceDialogProps) {
18
- const { data: balance, isLoading, error } = useCredentialBalance(credentialId)
19
-
20
- const formatDate = (timestamp: number | null) => {
21
- if (!timestamp) return '未知'
22
- return new Date(timestamp * 1000).toLocaleString('zh-CN')
23
- }
24
-
25
- const formatNumber = (num: number) => {
26
- return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
27
- }
28
-
29
- return (
30
- <Dialog open={open} onOpenChange={onOpenChange}>
31
- <DialogContent className="sm:max-w-md">
32
- <DialogHeader>
33
- <DialogTitle>
34
- 凭据 #{credentialId} 余额信息
35
- </DialogTitle>
36
- </DialogHeader>
37
-
38
- {isLoading && (
39
- <div className="flex items-center justify-center py-8">
40
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
41
- </div>
42
- )}
43
-
44
- {error && (() => {
45
- const parsed = parseError(error)
46
- return (
47
- <div className="py-6 space-y-3">
48
- <div className="flex items-center justify-center gap-2 text-red-500">
49
- <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
50
- <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" />
51
- </svg>
52
- <span className="font-medium">{parsed.title}</span>
53
- </div>
54
- {parsed.detail && (
55
- <div className="text-sm text-muted-foreground text-center px-4">
56
- {parsed.detail}
57
- </div>
58
- )}
59
- </div>
60
- )
61
- })()}
62
-
63
- {balance && (
64
- <div className="space-y-4">
65
- {/* 订阅类型 */}
66
- <div className="text-center">
67
- <span className="text-lg font-semibold">
68
- {balance.subscriptionTitle || '未知订阅类型'}
69
- </span>
70
- </div>
71
-
72
- {/* 使用进度 */}
73
- <div className="space-y-2">
74
- <div className="flex justify-between text-sm">
75
- <span>已使用: ${formatNumber(balance.currentUsage)}</span>
76
- <span>限额: ${formatNumber(balance.usageLimit)}</span>
77
- </div>
78
- <Progress value={balance.usagePercentage} />
79
- <div className="text-center text-sm text-muted-foreground">
80
- {balance.usagePercentage.toFixed(1)}% 已使用
81
- </div>
82
- </div>
83
-
84
- {/* 详细信息 */}
85
- <div className="grid grid-cols-2 gap-4 pt-4 border-t text-sm">
86
- <div>
87
- <span className="text-muted-foreground">剩余额度:</span>
88
- <span className="font-medium text-green-600">
89
- ${formatNumber(balance.remaining)}
90
- </span>
91
- </div>
92
- <div>
93
- <span className="text-muted-foreground">下次重置:</span>
94
- <span className="font-medium">
95
- {formatDate(balance.nextResetAt)}
96
- </span>
97
- </div>
98
- </div>
99
- </div>
100
- )}
101
- </DialogContent>
102
- </Dialog>
103
- )
104
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/credential-card.tsx DELETED
@@ -1,298 +0,0 @@
1
- import { useState } from 'react'
2
- import { toast } from 'sonner'
3
- import { RefreshCw, ChevronUp, ChevronDown, Wallet, Trash2 } from 'lucide-react'
4
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
5
- import { Button } from '@/components/ui/button'
6
- import { Badge } from '@/components/ui/badge'
7
- import { Switch } from '@/components/ui/switch'
8
- import { Input } from '@/components/ui/input'
9
- import {
10
- Dialog,
11
- DialogContent,
12
- DialogDescription,
13
- DialogFooter,
14
- DialogHeader,
15
- DialogTitle,
16
- } from '@/components/ui/dialog'
17
- import type { CredentialStatusItem } from '@/types/api'
18
- import {
19
- useSetDisabled,
20
- useSetPriority,
21
- useResetFailure,
22
- useDeleteCredential,
23
- } from '@/hooks/use-credentials'
24
-
25
- interface CredentialCardProps {
26
- credential: CredentialStatusItem
27
- onViewBalance: (id: number) => void
28
- }
29
-
30
- export function CredentialCard({ credential, onViewBalance }: CredentialCardProps) {
31
- const [editingPriority, setEditingPriority] = useState(false)
32
- const [priorityValue, setPriorityValue] = useState(String(credential.priority))
33
- const [showDeleteDialog, setShowDeleteDialog] = useState(false)
34
-
35
- const setDisabled = useSetDisabled()
36
- const setPriority = useSetPriority()
37
- const resetFailure = useResetFailure()
38
- const deleteCredential = useDeleteCredential()
39
-
40
- const handleToggleDisabled = () => {
41
- setDisabled.mutate(
42
- { id: credential.id, disabled: !credential.disabled },
43
- {
44
- onSuccess: (res) => {
45
- toast.success(res.message)
46
- },
47
- onError: (err) => {
48
- toast.error('操作失败: ' + (err as Error).message)
49
- },
50
- }
51
- )
52
- }
53
-
54
- const handlePriorityChange = () => {
55
- const newPriority = parseInt(priorityValue, 10)
56
- if (isNaN(newPriority) || newPriority < 0) {
57
- toast.error('优先级必须是非负整数')
58
- return
59
- }
60
- setPriority.mutate(
61
- { id: credential.id, priority: newPriority },
62
- {
63
- onSuccess: (res) => {
64
- toast.success(res.message)
65
- setEditingPriority(false)
66
- },
67
- onError: (err) => {
68
- toast.error('操作失败: ' + (err as Error).message)
69
- },
70
- }
71
- )
72
- }
73
-
74
- const handleReset = () => {
75
- resetFailure.mutate(credential.id, {
76
- onSuccess: (res) => {
77
- toast.success(res.message)
78
- },
79
- onError: (err) => {
80
- toast.error('操作失败: ' + (err as Error).message)
81
- },
82
- })
83
- }
84
-
85
- const handleDelete = () => {
86
- deleteCredential.mutate(credential.id, {
87
- onSuccess: (res) => {
88
- toast.success(res.message)
89
- setShowDeleteDialog(false)
90
- },
91
- onError: (err) => {
92
- toast.error('删除失败: ' + (err as Error).message)
93
- },
94
- })
95
- }
96
-
97
- const formatExpiry = (expiresAt: string | null) => {
98
- if (!expiresAt) return '未知'
99
- const date = new Date(expiresAt)
100
- const now = new Date()
101
- const diff = date.getTime() - now.getTime()
102
- if (diff < 0) return '已过期'
103
- const minutes = Math.floor(diff / 60000)
104
- if (minutes < 60) return `${minutes} 分钟`
105
- const hours = Math.floor(minutes / 60)
106
- if (hours < 24) return `${hours} 小时`
107
- return `${Math.floor(hours / 24)} 天`
108
- }
109
-
110
- return (
111
- <>
112
- <Card className={credential.isCurrent ? 'ring-2 ring-primary' : ''}>
113
- <CardHeader className="pb-2">
114
- <div className="flex items-center justify-between">
115
- <CardTitle className="text-lg flex items-center gap-2">
116
- 凭据 #{credential.id}
117
- {credential.isCurrent && (
118
- <Badge variant="success">当前</Badge>
119
- )}
120
- {credential.disabled && (
121
- <Badge variant="destructive">已禁用</Badge>
122
- )}
123
- </CardTitle>
124
- <div className="flex items-center gap-2">
125
- <span className="text-sm text-muted-foreground">启用</span>
126
- <Switch
127
- checked={!credential.disabled}
128
- onCheckedChange={handleToggleDisabled}
129
- disabled={setDisabled.isPending}
130
- />
131
- </div>
132
- </div>
133
- </CardHeader>
134
- <CardContent className="space-y-4">
135
- {/* 信息网格 */}
136
- <div className="grid grid-cols-2 gap-4 text-sm">
137
- <div>
138
- <span className="text-muted-foreground">优先级:</span>
139
- {editingPriority ? (
140
- <div className="inline-flex items-center gap-1 ml-1">
141
- <Input
142
- type="number"
143
- value={priorityValue}
144
- onChange={(e) => setPriorityValue(e.target.value)}
145
- className="w-16 h-7 text-sm"
146
- min="0"
147
- />
148
- <Button
149
- size="sm"
150
- variant="ghost"
151
- className="h-7 w-7 p-0"
152
- onClick={handlePriorityChange}
153
- disabled={setPriority.isPending}
154
- >
155
-
156
- </Button>
157
- <Button
158
- size="sm"
159
- variant="ghost"
160
- className="h-7 w-7 p-0"
161
- onClick={() => {
162
- setEditingPriority(false)
163
- setPriorityValue(String(credential.priority))
164
- }}
165
- >
166
-
167
- </Button>
168
- </div>
169
- ) : (
170
- <span
171
- className="font-medium cursor-pointer hover:underline ml-1"
172
- onClick={() => setEditingPriority(true)}
173
- >
174
- {credential.priority}
175
- <span className="text-xs text-muted-foreground ml-1">(点击编辑)</span>
176
- </span>
177
- )}
178
- </div>
179
- <div>
180
- <span className="text-muted-foreground">失败次数:</span>
181
- <span className={credential.failureCount > 0 ? 'text-red-500 font-medium' : ''}>
182
- {credential.failureCount}
183
- </span>
184
- </div>
185
- <div>
186
- <span className="text-muted-foreground">认证方式:</span>
187
- <span className="font-medium">{credential.authMethod || '未知'}</span>
188
- </div>
189
- <div>
190
- <span className="text-muted-foreground">Token 有效期:</span>
191
- <span className="font-medium">{formatExpiry(credential.expiresAt)}</span>
192
- </div>
193
- {credential.hasProfileArn && (
194
- <div className="col-span-2">
195
- <Badge variant="secondary">有 Profile ARN</Badge>
196
- </div>
197
- )}
198
- </div>
199
-
200
- {/* 操作按钮 */}
201
- <div className="flex flex-wrap gap-2 pt-2 border-t">
202
- <Button
203
- size="sm"
204
- variant="outline"
205
- onClick={handleReset}
206
- disabled={resetFailure.isPending || credential.failureCount === 0}
207
- >
208
- <RefreshCw className="h-4 w-4 mr-1" />
209
- 重置失败
210
- </Button>
211
- <Button
212
- size="sm"
213
- variant="outline"
214
- onClick={() => {
215
- const newPriority = Math.max(0, credential.priority - 1)
216
- setPriority.mutate(
217
- { id: credential.id, priority: newPriority },
218
- {
219
- onSuccess: (res) => toast.success(res.message),
220
- onError: (err) => toast.error('操作失败: ' + (err as Error).message),
221
- }
222
- )
223
- }}
224
- disabled={setPriority.isPending || credential.priority === 0}
225
- >
226
- <ChevronUp className="h-4 w-4 mr-1" />
227
- 提高优先级
228
- </Button>
229
- <Button
230
- size="sm"
231
- variant="outline"
232
- onClick={() => {
233
- const newPriority = credential.priority + 1
234
- setPriority.mutate(
235
- { id: credential.id, priority: newPriority },
236
- {
237
- onSuccess: (res) => toast.success(res.message),
238
- onError: (err) => toast.error('操作失败: ' + (err as Error).message),
239
- }
240
- )
241
- }}
242
- disabled={setPriority.isPending}
243
- >
244
- <ChevronDown className="h-4 w-4 mr-1" />
245
- 降低优先级
246
- </Button>
247
- <Button
248
- size="sm"
249
- variant="default"
250
- onClick={() => onViewBalance(credential.id)}
251
- >
252
- <Wallet className="h-4 w-4 mr-1" />
253
- 查看余额
254
- </Button>
255
- <Button
256
- size="sm"
257
- variant="destructive"
258
- onClick={() => setShowDeleteDialog(true)}
259
- disabled={!credential.disabled}
260
- title={!credential.disabled ? '需要先禁用凭据才能删除' : undefined}
261
- >
262
- <Trash2 className="h-4 w-4 mr-1" />
263
- 删除
264
- </Button>
265
- </div>
266
- </CardContent>
267
- </Card>
268
-
269
- {/* 删除确认对话框 */}
270
- <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
271
- <DialogContent>
272
- <DialogHeader>
273
- <DialogTitle>确认删除凭据</DialogTitle>
274
- <DialogDescription>
275
- 您确定要删除凭据 #{credential.id} 吗?此操作无法撤销。
276
- </DialogDescription>
277
- </DialogHeader>
278
- <DialogFooter>
279
- <Button
280
- variant="outline"
281
- onClick={() => setShowDeleteDialog(false)}
282
- disabled={deleteCredential.isPending}
283
- >
284
- 取消
285
- </Button>
286
- <Button
287
- variant="destructive"
288
- onClick={handleDelete}
289
- disabled={deleteCredential.isPending}
290
- >
291
- 确认删除
292
- </Button>
293
- </DialogFooter>
294
- </DialogContent>
295
- </Dialog>
296
- </>
297
- )
298
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/dashboard.tsx DELETED
@@ -1,186 +0,0 @@
1
- import { useState } from 'react'
2
- import { RefreshCw, LogOut, Moon, Sun, Server, Plus } from 'lucide-react'
3
- import { useQueryClient } from '@tanstack/react-query'
4
- import { toast } from 'sonner'
5
- import { storage } from '@/lib/storage'
6
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
7
- import { Button } from '@/components/ui/button'
8
- import { Badge } from '@/components/ui/badge'
9
- import { CredentialCard } from '@/components/credential-card'
10
- import { BalanceDialog } from '@/components/balance-dialog'
11
- import { AddCredentialDialog } from '@/components/add-credential-dialog'
12
- import { useCredentials } from '@/hooks/use-credentials'
13
-
14
- interface DashboardProps {
15
- onLogout: () => void
16
- }
17
-
18
- export function Dashboard({ onLogout }: DashboardProps) {
19
- const [selectedCredentialId, setSelectedCredentialId] = useState<number | null>(null)
20
- const [balanceDialogOpen, setBalanceDialogOpen] = useState(false)
21
- const [addDialogOpen, setAddDialogOpen] = useState(false)
22
- const [darkMode, setDarkMode] = useState(() => {
23
- if (typeof window !== 'undefined') {
24
- return document.documentElement.classList.contains('dark')
25
- }
26
- return false
27
- })
28
-
29
- const queryClient = useQueryClient()
30
- const { data, isLoading, error, refetch } = useCredentials()
31
-
32
- const toggleDarkMode = () => {
33
- setDarkMode(!darkMode)
34
- document.documentElement.classList.toggle('dark')
35
- }
36
-
37
- const handleViewBalance = (id: number) => {
38
- setSelectedCredentialId(id)
39
- setBalanceDialogOpen(true)
40
- }
41
-
42
- const handleRefresh = () => {
43
- refetch()
44
- toast.success('已刷新凭据列表')
45
- }
46
-
47
- const handleLogout = () => {
48
- storage.removeApiKey()
49
- queryClient.clear()
50
- onLogout()
51
- }
52
-
53
- if (isLoading) {
54
- return (
55
- <div className="min-h-screen flex items-center justify-center bg-background">
56
- <div className="text-center">
57
- <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
58
- <p className="text-muted-foreground">加载中...</p>
59
- </div>
60
- </div>
61
- )
62
- }
63
-
64
- if (error) {
65
- return (
66
- <div className="min-h-screen flex items-center justify-center bg-background p-4">
67
- <Card className="w-full max-w-md">
68
- <CardContent className="pt-6 text-center">
69
- <div className="text-red-500 mb-4">加载失败</div>
70
- <p className="text-muted-foreground mb-4">{(error as Error).message}</p>
71
- <div className="space-x-2">
72
- <Button onClick={() => refetch()}>重试</Button>
73
- <Button variant="outline" onClick={handleLogout}>重新登录</Button>
74
- </div>
75
- </CardContent>
76
- </Card>
77
- </div>
78
- )
79
- }
80
-
81
- return (
82
- <div className="min-h-screen bg-background">
83
- {/* 顶部导航 */}
84
- <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
85
- <div className="container flex h-14 items-center justify-between px-4 md:px-8">
86
- <div className="flex items-center gap-2">
87
- <Server className="h-5 w-5" />
88
- <span className="font-semibold">Kiro Admin</span>
89
- </div>
90
- <div className="flex items-center gap-2">
91
- <Button variant="ghost" size="icon" onClick={toggleDarkMode}>
92
- {darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
93
- </Button>
94
- <Button variant="ghost" size="icon" onClick={handleRefresh}>
95
- <RefreshCw className="h-5 w-5" />
96
- </Button>
97
- <Button variant="ghost" size="icon" onClick={handleLogout}>
98
- <LogOut className="h-5 w-5" />
99
- </Button>
100
- </div>
101
- </div>
102
- </header>
103
-
104
- {/* 主内容 */}
105
- <main className="container px-4 md:px-8 py-6">
106
- {/* 统计卡片 */}
107
- <div className="grid gap-4 md:grid-cols-3 mb-6">
108
- <Card>
109
- <CardHeader className="pb-2">
110
- <CardTitle className="text-sm font-medium text-muted-foreground">
111
- 凭据总数
112
- </CardTitle>
113
- </CardHeader>
114
- <CardContent>
115
- <div className="text-2xl font-bold">{data?.total || 0}</div>
116
- </CardContent>
117
- </Card>
118
- <Card>
119
- <CardHeader className="pb-2">
120
- <CardTitle className="text-sm font-medium text-muted-foreground">
121
- 可用凭据
122
- </CardTitle>
123
- </CardHeader>
124
- <CardContent>
125
- <div className="text-2xl font-bold text-green-600">{data?.available || 0}</div>
126
- </CardContent>
127
- </Card>
128
- <Card>
129
- <CardHeader className="pb-2">
130
- <CardTitle className="text-sm font-medium text-muted-foreground">
131
- 当前活跃
132
- </CardTitle>
133
- </CardHeader>
134
- <CardContent>
135
- <div className="text-2xl font-bold flex items-center gap-2">
136
- #{data?.currentId || '-'}
137
- <Badge variant="success">活跃</Badge>
138
- </div>
139
- </CardContent>
140
- </Card>
141
- </div>
142
-
143
- {/* 凭据列表 */}
144
- <div className="space-y-4">
145
- <div className="flex items-center justify-between">
146
- <h2 className="text-xl font-semibold">凭据管理</h2>
147
- <Button onClick={() => setAddDialogOpen(true)} size="sm">
148
- <Plus className="h-4 w-4 mr-2" />
149
- 添加凭据
150
- </Button>
151
- </div>
152
- {data?.credentials.length === 0 ? (
153
- <Card>
154
- <CardContent className="py-8 text-center text-muted-foreground">
155
- 暂无凭据
156
- </CardContent>
157
- </Card>
158
- ) : (
159
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
160
- {data?.credentials.map((credential) => (
161
- <CredentialCard
162
- key={credential.id}
163
- credential={credential}
164
- onViewBalance={handleViewBalance}
165
- />
166
- ))}
167
- </div>
168
- )}
169
- </div>
170
- </main>
171
-
172
- {/* 余额对话框 */}
173
- <BalanceDialog
174
- credentialId={selectedCredentialId}
175
- open={balanceDialogOpen}
176
- onOpenChange={setBalanceDialogOpen}
177
- />
178
-
179
- {/* 添加凭据对话框 */}
180
- <AddCredentialDialog
181
- open={addDialogOpen}
182
- onOpenChange={setAddDialogOpen}
183
- />
184
- </div>
185
- )
186
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/login-page.tsx DELETED
@@ -1,62 +0,0 @@
1
- import { useState, useEffect } from 'react'
2
- import { KeyRound } from 'lucide-react'
3
- import { storage } from '@/lib/storage'
4
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5
- import { Input } from '@/components/ui/input'
6
- import { Button } from '@/components/ui/button'
7
-
8
- interface LoginPageProps {
9
- onLogin: (apiKey: string) => void
10
- }
11
-
12
- export function LoginPage({ onLogin }: LoginPageProps) {
13
- const [apiKey, setApiKey] = useState('')
14
-
15
- useEffect(() => {
16
- // 从 storage 读取保存的 API Key
17
- const savedKey = storage.getApiKey()
18
- if (savedKey) {
19
- setApiKey(savedKey)
20
- }
21
- }, [])
22
-
23
- const handleSubmit = (e: React.FormEvent) => {
24
- e.preventDefault()
25
- if (apiKey.trim()) {
26
- storage.setApiKey(apiKey.trim())
27
- onLogin(apiKey.trim())
28
- }
29
- }
30
-
31
- return (
32
- <div className="min-h-screen flex items-center justify-center bg-background p-4">
33
- <Card className="w-full max-w-md">
34
- <CardHeader className="text-center">
35
- <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
36
- <KeyRound className="h-6 w-6 text-primary" />
37
- </div>
38
- <CardTitle className="text-2xl">Kiro Admin</CardTitle>
39
- <CardDescription>
40
- 请输入 Admin API Key 以访问管理面板
41
- </CardDescription>
42
- </CardHeader>
43
- <CardContent>
44
- <form onSubmit={handleSubmit} className="space-y-4">
45
- <div className="space-y-2">
46
- <Input
47
- type="password"
48
- placeholder="Admin API Key"
49
- value={apiKey}
50
- onChange={(e) => setApiKey(e.target.value)}
51
- className="text-center"
52
- />
53
- </div>
54
- <Button type="submit" className="w-full" disabled={!apiKey.trim()}>
55
- 登录
56
- </Button>
57
- </form>
58
- </CardContent>
59
- </Card>
60
- </div>
61
- )
62
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/ui/badge.tsx DELETED
@@ -1,39 +0,0 @@
1
- import * as React from 'react'
2
- import { cva, type VariantProps } from 'class-variance-authority'
3
- import { cn } from '@/lib/utils'
4
-
5
- const badgeVariants = cva(
6
- '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',
7
- {
8
- variants: {
9
- variant: {
10
- default:
11
- 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
12
- secondary:
13
- 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
14
- destructive:
15
- 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
16
- outline: 'text-foreground',
17
- success:
18
- 'border-transparent bg-green-500 text-white hover:bg-green-500/80',
19
- warning:
20
- 'border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80',
21
- },
22
- },
23
- defaultVariants: {
24
- variant: 'default',
25
- },
26
- }
27
- )
28
-
29
- export interface BadgeProps
30
- extends React.HTMLAttributes<HTMLDivElement>,
31
- VariantProps<typeof badgeVariants> {}
32
-
33
- function Badge({ className, variant, ...props }: BadgeProps) {
34
- return (
35
- <div className={cn(badgeVariants({ variant }), className)} {...props} />
36
- )
37
- }
38
-
39
- export { Badge, badgeVariants }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/ui/button.tsx DELETED
@@ -1,55 +0,0 @@
1
- import * as React from 'react'
2
- import { Slot } from '@radix-ui/react-slot'
3
- import { cva, type VariantProps } from 'class-variance-authority'
4
- import { cn } from '@/lib/utils'
5
-
6
- const buttonVariants = cva(
7
- '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',
8
- {
9
- variants: {
10
- variant: {
11
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
12
- destructive:
13
- 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
14
- outline:
15
- 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
16
- secondary:
17
- 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
18
- ghost: 'hover:bg-accent hover:text-accent-foreground',
19
- link: 'text-primary underline-offset-4 hover:underline',
20
- },
21
- size: {
22
- default: 'h-10 px-4 py-2',
23
- sm: 'h-9 rounded-md px-3',
24
- lg: 'h-11 rounded-md px-8',
25
- icon: 'h-10 w-10',
26
- },
27
- },
28
- defaultVariants: {
29
- variant: 'default',
30
- size: 'default',
31
- },
32
- }
33
- )
34
-
35
- export interface ButtonProps
36
- extends React.ButtonHTMLAttributes<HTMLButtonElement>,
37
- VariantProps<typeof buttonVariants> {
38
- asChild?: boolean
39
- }
40
-
41
- const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
42
- ({ className, variant, size, asChild = false, ...props }, ref) => {
43
- const Comp = asChild ? Slot : 'button'
44
- return (
45
- <Comp
46
- className={cn(buttonVariants({ variant, size, className }))}
47
- ref={ref}
48
- {...props}
49
- />
50
- )
51
- }
52
- )
53
- Button.displayName = 'Button'
54
-
55
- export { Button, buttonVariants }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/ui/card.tsx DELETED
@@ -1,78 +0,0 @@
1
- import * as React from 'react'
2
- import { cn } from '@/lib/utils'
3
-
4
- const Card = React.forwardRef<
5
- HTMLDivElement,
6
- React.HTMLAttributes<HTMLDivElement>
7
- >(({ className, ...props }, ref) => (
8
- <div
9
- ref={ref}
10
- className={cn(
11
- 'rounded-lg border bg-card text-card-foreground shadow-sm',
12
- className
13
- )}
14
- {...props}
15
- />
16
- ))
17
- Card.displayName = 'Card'
18
-
19
- const CardHeader = React.forwardRef<
20
- HTMLDivElement,
21
- React.HTMLAttributes<HTMLDivElement>
22
- >(({ className, ...props }, ref) => (
23
- <div
24
- ref={ref}
25
- className={cn('flex flex-col space-y-1.5 p-6', className)}
26
- {...props}
27
- />
28
- ))
29
- CardHeader.displayName = 'CardHeader'
30
-
31
- const CardTitle = React.forwardRef<
32
- HTMLParagraphElement,
33
- React.HTMLAttributes<HTMLHeadingElement>
34
- >(({ className, ...props }, ref) => (
35
- <h3
36
- ref={ref}
37
- className={cn(
38
- 'text-2xl font-semibold leading-none tracking-tight',
39
- className
40
- )}
41
- {...props}
42
- />
43
- ))
44
- CardTitle.displayName = 'CardTitle'
45
-
46
- const CardDescription = React.forwardRef<
47
- HTMLParagraphElement,
48
- React.HTMLAttributes<HTMLParagraphElement>
49
- >(({ className, ...props }, ref) => (
50
- <p
51
- ref={ref}
52
- className={cn('text-sm text-muted-foreground', className)}
53
- {...props}
54
- />
55
- ))
56
- CardDescription.displayName = 'CardDescription'
57
-
58
- const CardContent = React.forwardRef<
59
- HTMLDivElement,
60
- React.HTMLAttributes<HTMLDivElement>
61
- >(({ className, ...props }, ref) => (
62
- <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
63
- ))
64
- CardContent.displayName = 'CardContent'
65
-
66
- const CardFooter = React.forwardRef<
67
- HTMLDivElement,
68
- React.HTMLAttributes<HTMLDivElement>
69
- >(({ className, ...props }, ref) => (
70
- <div
71
- ref={ref}
72
- className={cn('flex items-center p-6 pt-0', className)}
73
- {...props}
74
- />
75
- ))
76
- CardFooter.displayName = 'CardFooter'
77
-
78
- export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/ui/dialog.tsx DELETED
@@ -1,119 +0,0 @@
1
- import * as React from 'react'
2
- import * as DialogPrimitive from '@radix-ui/react-dialog'
3
- import { X } from 'lucide-react'
4
- import { cn } from '@/lib/utils'
5
-
6
- const Dialog = DialogPrimitive.Root
7
-
8
- const DialogTrigger = DialogPrimitive.Trigger
9
-
10
- const DialogPortal = DialogPrimitive.Portal
11
-
12
- const DialogClose = DialogPrimitive.Close
13
-
14
- const DialogOverlay = React.forwardRef<
15
- React.ElementRef<typeof DialogPrimitive.Overlay>,
16
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
17
- >(({ className, ...props }, ref) => (
18
- <DialogPrimitive.Overlay
19
- ref={ref}
20
- className={cn(
21
- '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',
22
- className
23
- )}
24
- {...props}
25
- />
26
- ))
27
- DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
28
-
29
- const DialogContent = React.forwardRef<
30
- React.ElementRef<typeof DialogPrimitive.Content>,
31
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
32
- >(({ className, children, ...props }, ref) => (
33
- <DialogPortal>
34
- <DialogOverlay />
35
- <DialogPrimitive.Content
36
- ref={ref}
37
- className={cn(
38
- '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',
39
- className
40
- )}
41
- {...props}
42
- >
43
- {children}
44
- <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">
45
- <X className="h-4 w-4" />
46
- <span className="sr-only">关闭</span>
47
- </DialogPrimitive.Close>
48
- </DialogPrimitive.Content>
49
- </DialogPortal>
50
- ))
51
- DialogContent.displayName = DialogPrimitive.Content.displayName
52
-
53
- const DialogHeader = ({
54
- className,
55
- ...props
56
- }: React.HTMLAttributes<HTMLDivElement>) => (
57
- <div
58
- className={cn(
59
- 'flex flex-col space-y-1.5 text-center sm:text-left',
60
- className
61
- )}
62
- {...props}
63
- />
64
- )
65
- DialogHeader.displayName = 'DialogHeader'
66
-
67
- const DialogFooter = ({
68
- className,
69
- ...props
70
- }: React.HTMLAttributes<HTMLDivElement>) => (
71
- <div
72
- className={cn(
73
- 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
74
- className
75
- )}
76
- {...props}
77
- />
78
- )
79
- DialogFooter.displayName = 'DialogFooter'
80
-
81
- const DialogTitle = React.forwardRef<
82
- React.ElementRef<typeof DialogPrimitive.Title>,
83
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
84
- >(({ className, ...props }, ref) => (
85
- <DialogPrimitive.Title
86
- ref={ref}
87
- className={cn(
88
- 'text-lg font-semibold leading-none tracking-tight',
89
- className
90
- )}
91
- {...props}
92
- />
93
- ))
94
- DialogTitle.displayName = DialogPrimitive.Title.displayName
95
-
96
- const DialogDescription = React.forwardRef<
97
- React.ElementRef<typeof DialogPrimitive.Description>,
98
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
99
- >(({ className, ...props }, ref) => (
100
- <DialogPrimitive.Description
101
- ref={ref}
102
- className={cn('text-sm text-muted-foreground', className)}
103
- {...props}
104
- />
105
- ))
106
- DialogDescription.displayName = DialogPrimitive.Description.displayName
107
-
108
- export {
109
- Dialog,
110
- DialogPortal,
111
- DialogOverlay,
112
- DialogClose,
113
- DialogTrigger,
114
- DialogContent,
115
- DialogHeader,
116
- DialogFooter,
117
- DialogTitle,
118
- DialogDescription,
119
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/ui/input.tsx DELETED
@@ -1,24 +0,0 @@
1
- import * as React from 'react'
2
- import { cn } from '@/lib/utils'
3
-
4
- export interface InputProps
5
- extends React.InputHTMLAttributes<HTMLInputElement> {}
6
-
7
- const Input = React.forwardRef<HTMLInputElement, InputProps>(
8
- ({ className, type, ...props }, ref) => {
9
- return (
10
- <input
11
- type={type}
12
- className={cn(
13
- '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',
14
- className
15
- )}
16
- ref={ref}
17
- {...props}
18
- />
19
- )
20
- }
21
- )
22
- Input.displayName = 'Input'
23
-
24
- export { Input }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/ui/progress.tsx DELETED
@@ -1,35 +0,0 @@
1
- import * as React from 'react'
2
- import { cn } from '@/lib/utils'
3
-
4
- interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
5
- value?: number
6
- max?: number
7
- }
8
-
9
- const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
10
- ({ className, value = 0, max = 100, ...props }, ref) => {
11
- const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
12
-
13
- return (
14
- <div
15
- ref={ref}
16
- className={cn(
17
- 'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
18
- className
19
- )}
20
- {...props}
21
- >
22
- <div
23
- className={cn(
24
- 'h-full transition-all',
25
- percentage > 80 ? 'bg-red-500' : percentage > 60 ? 'bg-yellow-500' : 'bg-green-500'
26
- )}
27
- style={{ width: `${percentage}%` }}
28
- />
29
- </div>
30
- )
31
- }
32
- )
33
- Progress.displayName = 'Progress'
34
-
35
- export { Progress }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/ui/sonner.tsx DELETED
@@ -1,25 +0,0 @@
1
- import { Toaster as Sonner } from 'sonner'
2
-
3
- type ToasterProps = React.ComponentProps<typeof Sonner>
4
-
5
- const Toaster = ({ ...props }: ToasterProps) => {
6
- return (
7
- <Sonner
8
- className="toaster group"
9
- toastOptions={{
10
- classNames: {
11
- toast:
12
- 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
13
- description: 'group-[.toast]:text-muted-foreground',
14
- actionButton:
15
- 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
16
- cancelButton:
17
- 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
18
- },
19
- }}
20
- {...props}
21
- />
22
- )
23
- }
24
-
25
- export { Toaster }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/components/ui/switch.tsx DELETED
@@ -1,26 +0,0 @@
1
- import * as React from 'react'
2
- import * as SwitchPrimitives from '@radix-ui/react-switch'
3
- import { cn } from '@/lib/utils'
4
-
5
- const Switch = React.forwardRef<
6
- React.ElementRef<typeof SwitchPrimitives.Root>,
7
- React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
8
- >(({ className, ...props }, ref) => (
9
- <SwitchPrimitives.Root
10
- className={cn(
11
- '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',
12
- className
13
- )}
14
- {...props}
15
- ref={ref}
16
- >
17
- <SwitchPrimitives.Thumb
18
- className={cn(
19
- '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'
20
- )}
21
- />
22
- </SwitchPrimitives.Root>
23
- ))
24
- Switch.displayName = SwitchPrimitives.Root.displayName
25
-
26
- export { Switch }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/hooks/use-credentials.ts DELETED
@@ -1,87 +0,0 @@
1
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
- import {
3
- getCredentials,
4
- setCredentialDisabled,
5
- setCredentialPriority,
6
- resetCredentialFailure,
7
- getCredentialBalance,
8
- addCredential,
9
- deleteCredential,
10
- } from '@/api/credentials'
11
- import type { AddCredentialRequest } from '@/types/api'
12
-
13
- // 查询凭据列表
14
- export function useCredentials() {
15
- return useQuery({
16
- queryKey: ['credentials'],
17
- queryFn: getCredentials,
18
- refetchInterval: 30000, // 每 30 秒刷新一次
19
- })
20
- }
21
-
22
- // 查询凭据余额
23
- export function useCredentialBalance(id: number | null) {
24
- return useQuery({
25
- queryKey: ['credential-balance', id],
26
- queryFn: () => getCredentialBalance(id!),
27
- enabled: id !== null,
28
- retry: false, // 余额查询失败时不重试(避免重复请求被封禁的账号)
29
- })
30
- }
31
-
32
- // 设置禁用状态
33
- export function useSetDisabled() {
34
- const queryClient = useQueryClient()
35
- return useMutation({
36
- mutationFn: ({ id, disabled }: { id: number; disabled: boolean }) =>
37
- setCredentialDisabled(id, disabled),
38
- onSuccess: () => {
39
- queryClient.invalidateQueries({ queryKey: ['credentials'] })
40
- },
41
- })
42
- }
43
-
44
- // 设置优先级
45
- export function useSetPriority() {
46
- const queryClient = useQueryClient()
47
- return useMutation({
48
- mutationFn: ({ id, priority }: { id: number; priority: number }) =>
49
- setCredentialPriority(id, priority),
50
- onSuccess: () => {
51
- queryClient.invalidateQueries({ queryKey: ['credentials'] })
52
- },
53
- })
54
- }
55
-
56
- // 重置失败计数
57
- export function useResetFailure() {
58
- const queryClient = useQueryClient()
59
- return useMutation({
60
- mutationFn: (id: number) => resetCredentialFailure(id),
61
- onSuccess: () => {
62
- queryClient.invalidateQueries({ queryKey: ['credentials'] })
63
- },
64
- })
65
- }
66
-
67
- // 添加新凭据
68
- export function useAddCredential() {
69
- const queryClient = useQueryClient()
70
- return useMutation({
71
- mutationFn: (req: AddCredentialRequest) => addCredential(req),
72
- onSuccess: () => {
73
- queryClient.invalidateQueries({ queryKey: ['credentials'] })
74
- },
75
- })
76
- }
77
-
78
- // 删除凭据
79
- export function useDeleteCredential() {
80
- const queryClient = useQueryClient()
81
- return useMutation({
82
- mutationFn: (id: number) => deleteCredential(id),
83
- onSuccess: () => {
84
- queryClient.invalidateQueries({ queryKey: ['credentials'] })
85
- },
86
- })
87
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/index.css DELETED
@@ -1,60 +0,0 @@
1
- @tailwind base;
2
- @tailwind components;
3
- @tailwind utilities;
4
-
5
- @layer base {
6
- :root {
7
- --background: 0 0% 100%;
8
- --foreground: 222.2 84% 4.9%;
9
- --card: 0 0% 100%;
10
- --card-foreground: 222.2 84% 4.9%;
11
- --popover: 0 0% 100%;
12
- --popover-foreground: 222.2 84% 4.9%;
13
- --primary: 222.2 47.4% 11.2%;
14
- --primary-foreground: 210 40% 98%;
15
- --secondary: 210 40% 96.1%;
16
- --secondary-foreground: 222.2 47.4% 11.2%;
17
- --muted: 210 40% 96.1%;
18
- --muted-foreground: 215.4 16.3% 46.9%;
19
- --accent: 210 40% 96.1%;
20
- --accent-foreground: 222.2 47.4% 11.2%;
21
- --destructive: 0 84.2% 60.2%;
22
- --destructive-foreground: 210 40% 98%;
23
- --border: 214.3 31.8% 91.4%;
24
- --input: 214.3 31.8% 91.4%;
25
- --ring: 222.2 84% 4.9%;
26
- --radius: 0.5rem;
27
- }
28
-
29
- .dark {
30
- --background: 222.2 84% 4.9%;
31
- --foreground: 210 40% 98%;
32
- --card: 222.2 84% 4.9%;
33
- --card-foreground: 210 40% 98%;
34
- --popover: 222.2 84% 4.9%;
35
- --popover-foreground: 210 40% 98%;
36
- --primary: 210 40% 98%;
37
- --primary-foreground: 222.2 47.4% 11.2%;
38
- --secondary: 217.2 32.6% 17.5%;
39
- --secondary-foreground: 210 40% 98%;
40
- --muted: 217.2 32.6% 17.5%;
41
- --muted-foreground: 215 20.2% 65.1%;
42
- --accent: 217.2 32.6% 17.5%;
43
- --accent-foreground: 210 40% 98%;
44
- --destructive: 0 62.8% 30.6%;
45
- --destructive-foreground: 210 40% 98%;
46
- --border: 217.2 32.6% 17.5%;
47
- --input: 217.2 32.6% 17.5%;
48
- --ring: 212.7 26.8% 83.9%;
49
- }
50
- }
51
-
52
- @layer base {
53
- * {
54
- @apply border-border;
55
- }
56
- body {
57
- @apply bg-background text-foreground;
58
- font-feature-settings: "rlig" 1, "calt" 1;
59
- }
60
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/lib/storage.ts DELETED
@@ -1,7 +0,0 @@
1
- const API_KEY_STORAGE_KEY = 'adminApiKey'
2
-
3
- export const storage = {
4
- getApiKey: () => localStorage.getItem(API_KEY_STORAGE_KEY),
5
- setApiKey: (key: string) => localStorage.setItem(API_KEY_STORAGE_KEY, key),
6
- removeApiKey: () => localStorage.removeItem(API_KEY_STORAGE_KEY),
7
- }
 
 
 
 
 
 
 
 
admin-ui/src/lib/utils.ts DELETED
@@ -1,106 +0,0 @@
1
- import { clsx, type ClassValue } from 'clsx'
2
- import { twMerge } from 'tailwind-merge'
3
-
4
- export function cn(...inputs: ClassValue[]) {
5
- return twMerge(clsx(inputs))
6
- }
7
-
8
- /**
9
- * 解析后端错误响应,提取用户友好的错误信息
10
- */
11
- export interface ParsedError {
12
- /** 简短的错误标题 */
13
- title: string
14
- /** 详细的错误描述 */
15
- detail?: string
16
- /** 错误类型 */
17
- type?: string
18
- }
19
-
20
- /**
21
- * 从错误对象中提取错误消息
22
- * 支持 Axios 错误和普通 Error 对象
23
- */
24
- export function extractErrorMessage(error: unknown): string {
25
- const parsed = parseError(error)
26
- return parsed.title
27
- }
28
-
29
- /**
30
- * 解析错误,返回结构化的错误信息
31
- */
32
- export function parseError(error: unknown): ParsedError {
33
- if (!error || typeof error !== 'object') {
34
- return { title: '未知错误' }
35
- }
36
-
37
- const axiosError = error as Record<string, unknown>
38
- const response = axiosError.response as Record<string, unknown> | undefined
39
- const data = response?.data as Record<string, unknown> | undefined
40
- const errorObj = data?.error as Record<string, unknown> | undefined
41
-
42
- // 尝试从后端错误响应中提取信息
43
- if (errorObj && typeof errorObj.message === 'string') {
44
- const message = errorObj.message
45
- const type = typeof errorObj.type === 'string' ? errorObj.type : undefined
46
-
47
- // 解析嵌套的错误信息(如:上游服务错误: 权限不足: 403 {...})
48
- const parsed = parseNestedErrorMessage(message)
49
-
50
- return {
51
- title: parsed.title,
52
- detail: parsed.detail,
53
- type,
54
- }
55
- }
56
-
57
- // 回退到 Error.message
58
- if ('message' in axiosError && typeof axiosError.message === 'string') {
59
- return { title: axiosError.message }
60
- }
61
-
62
- return { title: '未知错误' }
63
- }
64
-
65
- /**
66
- * 解析嵌套的错误消息
67
- * 例如:"上游服务错误: 权限不足,无法获取使用额度: 403 Forbidden {...}"
68
- */
69
- function parseNestedErrorMessage(message: string): { title: string; detail?: string } {
70
- // 尝试提取 HTTP 状态码(如 403、502 等)
71
- const statusMatch = message.match(/(\d{3})\s+\w+/)
72
- const statusCode = statusMatch ? statusMatch[1] : null
73
-
74
- // 尝试提取 JSON 中的 message 字段
75
- const jsonMatch = message.match(/\{[^{}]*"message"\s*:\s*"([^"]+)"[^{}]*\}/)
76
- if (jsonMatch) {
77
- const innerMessage = jsonMatch[1]
78
- // 提取主要错误原因(去掉前缀)
79
- const parts = message.split(':').map(s => s.trim())
80
- const mainReason = parts.length > 1 ? parts[1].split(':')[0] : parts[0]
81
-
82
- // 在 title 中包含状态码
83
- const title = statusCode
84
- ? `${mainReason || '服务错误'} (${statusCode})`
85
- : (mainReason || '服务错误')
86
-
87
- return {
88
- title,
89
- detail: innerMessage,
90
- }
91
- }
92
-
93
- // 尝试按冒号分割,提取主要信息
94
- const colonParts = message.split(':')
95
- if (colonParts.length >= 2) {
96
- const mainPart = colonParts[1].trim().split(':')[0].trim()
97
- const title = statusCode ? `${mainPart} (${statusCode})` : mainPart
98
-
99
- return {
100
- title,
101
- detail: colonParts.slice(2).join(':').trim() || undefined,
102
- }
103
- }
104
-
105
- return { title: message }
106
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/main.tsx DELETED
@@ -1,22 +0,0 @@
1
- import React from 'react'
2
- import ReactDOM from 'react-dom/client'
3
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
- import App from './App'
5
- import './index.css'
6
-
7
- const queryClient = new QueryClient({
8
- defaultOptions: {
9
- queries: {
10
- staleTime: 5000,
11
- refetchOnWindowFocus: false,
12
- },
13
- },
14
- })
15
-
16
- ReactDOM.createRoot(document.getElementById('root')!).render(
17
- <React.StrictMode>
18
- <QueryClientProvider client={queryClient}>
19
- <App />
20
- </QueryClientProvider>
21
- </React.StrictMode>,
22
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/src/types/api.ts DELETED
@@ -1,69 +0,0 @@
1
- // 凭据状态响应
2
- export interface CredentialsStatusResponse {
3
- total: number
4
- available: number
5
- currentId: number
6
- credentials: CredentialStatusItem[]
7
- }
8
-
9
- // 单个凭据状态
10
- export interface CredentialStatusItem {
11
- id: number
12
- priority: number
13
- disabled: boolean
14
- failureCount: number
15
- isCurrent: boolean
16
- expiresAt: string | null
17
- authMethod: string | null
18
- hasProfileArn: boolean
19
- }
20
-
21
- // 余额响应
22
- export interface BalanceResponse {
23
- id: number
24
- subscriptionTitle: string | null
25
- currentUsage: number
26
- usageLimit: number
27
- remaining: number
28
- usagePercentage: number
29
- nextResetAt: number | null
30
- }
31
-
32
- // 成功响应
33
- export interface SuccessResponse {
34
- success: boolean
35
- message: string
36
- }
37
-
38
- // 错误响应
39
- export interface AdminErrorResponse {
40
- error: {
41
- type: string
42
- message: string
43
- }
44
- }
45
-
46
- // 请求类型
47
- export interface SetDisabledRequest {
48
- disabled: boolean
49
- }
50
-
51
- export interface SetPriorityRequest {
52
- priority: number
53
- }
54
-
55
- // 添加凭据请求
56
- export interface AddCredentialRequest {
57
- refreshToken: string
58
- authMethod?: 'social' | 'idc' | 'builder-id'
59
- clientId?: string
60
- clientSecret?: string
61
- priority?: number
62
- }
63
-
64
- // 添加凭据响应
65
- export interface AddCredentialResponse {
66
- success: boolean
67
- message: string
68
- credentialId: number
69
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/tailwind.config.js DELETED
@@ -1,53 +0,0 @@
1
- /** @type {import('tailwindcss').Config} */
2
- export default {
3
- darkMode: 'class',
4
- content: [
5
- './index.html',
6
- './src/**/*.{js,ts,jsx,tsx}',
7
- ],
8
- theme: {
9
- extend: {
10
- colors: {
11
- border: 'hsl(var(--border))',
12
- input: 'hsl(var(--input))',
13
- ring: 'hsl(var(--ring))',
14
- background: 'hsl(var(--background))',
15
- foreground: 'hsl(var(--foreground))',
16
- primary: {
17
- DEFAULT: 'hsl(var(--primary))',
18
- foreground: 'hsl(var(--primary-foreground))',
19
- },
20
- secondary: {
21
- DEFAULT: 'hsl(var(--secondary))',
22
- foreground: 'hsl(var(--secondary-foreground))',
23
- },
24
- destructive: {
25
- DEFAULT: 'hsl(var(--destructive))',
26
- foreground: 'hsl(var(--destructive-foreground))',
27
- },
28
- muted: {
29
- DEFAULT: 'hsl(var(--muted))',
30
- foreground: 'hsl(var(--muted-foreground))',
31
- },
32
- accent: {
33
- DEFAULT: 'hsl(var(--accent))',
34
- foreground: 'hsl(var(--accent-foreground))',
35
- },
36
- popover: {
37
- DEFAULT: 'hsl(var(--popover))',
38
- foreground: 'hsl(var(--popover-foreground))',
39
- },
40
- card: {
41
- DEFAULT: 'hsl(var(--card))',
42
- foreground: 'hsl(var(--card-foreground))',
43
- },
44
- },
45
- borderRadius: {
46
- lg: 'var(--radius)',
47
- md: 'calc(var(--radius) - 2px)',
48
- sm: 'calc(var(--radius) - 4px)',
49
- },
50
- },
51
- },
52
- plugins: [],
53
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/tsconfig.json DELETED
@@ -1,24 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "useDefineForClassFields": true,
5
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
- "module": "ESNext",
7
- "skipLibCheck": true,
8
- "moduleResolution": "bundler",
9
- "allowImportingTsExtensions": true,
10
- "isolatedModules": true,
11
- "moduleDetection": "force",
12
- "noEmit": true,
13
- "jsx": "react-jsx",
14
- "strict": true,
15
- "noUnusedLocals": true,
16
- "noUnusedParameters": true,
17
- "noFallthroughCasesInSwitch": true,
18
- "baseUrl": ".",
19
- "paths": {
20
- "@/*": ["./src/*"]
21
- }
22
- },
23
- "include": ["src"]
24
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
admin-ui/vite.config.ts DELETED
@@ -1,25 +0,0 @@
1
- import { defineConfig } from 'vite'
2
- import react from '@vitejs/plugin-react-swc'
3
- import path from 'path'
4
-
5
- export default defineConfig({
6
- plugins: [react()],
7
- base: '/admin/',
8
- resolve: {
9
- alias: {
10
- '@': path.resolve(__dirname, './src'),
11
- },
12
- },
13
- server: {
14
- proxy: {
15
- '/api': {
16
- target: 'http://localhost:8080',
17
- changeOrigin: true,
18
- },
19
- },
20
- },
21
- build: {
22
- outDir: 'dist',
23
- emptyOutDir: true,
24
- },
25
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.example.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "host": "127.0.0.1",
3
- "port": 8990,
4
- "apiKey": "sk-kiro-rs-qazWSXedcRFV123456",
5
- "region": "us-east-1",
6
- "adminApiKey": "sk-admin-your-secret-key"
7
- }
 
 
 
 
 
 
 
 
credentials.example.idc.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "refreshToken": "xxxxxxxxxxxxxxxxxxxx",
3
- "expiresAt": "2025-12-31T02:32:45.144Z",
4
- "authMethod": "idc",
5
- "clientId": "xxxxxxxxx",
6
- "clientSecret": "xxxxxxxxx",
7
- "region": "us-east-2",
8
- "machineId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
9
- }
 
 
 
 
 
 
 
 
 
 
credentials.example.multiple.json DELETED
@@ -1,19 +0,0 @@
1
- [
2
- {
3
- "refreshToken": "xxxxxxxxxxxxxxxxxxxx",
4
- "expiresAt": "2025-12-31T02:32:45.144Z",
5
- "authMethod": "social",
6
- "machineId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
7
- "priority": 0
8
- },
9
- {
10
- "refreshToken": "yyyyyyyyyyyyyyyyyyyy",
11
- "expiresAt": "2025-12-31T02:32:45.144Z",
12
- "authMethod": "idc",
13
- "clientId": "xxxxxxxxx",
14
- "clientSecret": "xxxxxxxxx",
15
- "region": "us-east-2",
16
- "machineId": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
17
- "priority": 1
18
- }
19
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
credentials.example.social.json DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "refreshToken": "xxxxxxxxxxxxxxxxxxxx",
3
- "expiresAt": "2025-12-31T02:32:45.144Z",
4
- "authMethod": "social",
5
- "machineId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
6
- }
 
 
 
 
 
 
 
entrypoint.sh DELETED
@@ -1,61 +0,0 @@
1
- #!/bin/sh
2
-
3
- # 从环境变量生成 config.json
4
- if [ -n "${ADMIN_API_KEY}" ]; then
5
- # 如果设置了 ADMIN_API_KEY,包含在配置中
6
- cat > /app/config/config.json << EOF
7
- {
8
- "host": "0.0.0.0",
9
- "port": 7860,
10
- "apiKey": "${API_KEY:-sk-kiro-rs-default}",
11
- "region": "${REGION:-us-east-1}",
12
- "adminApiKey": "${ADMIN_API_KEY}"
13
- }
14
- EOF
15
- else
16
- # 否则不包含 adminApiKey
17
- cat > /app/config/config.json << EOF
18
- {
19
- "host": "0.0.0.0",
20
- "port": 7860,
21
- "apiKey": "${API_KEY:-sk-kiro-rs-default}",
22
- "region": "${REGION:-us-east-1}"
23
- }
24
- EOF
25
- fi
26
-
27
- # 从环境变量生成 credentials.json
28
- # 支持两种模式:
29
- # 1. 多凭据模式:通过 CREDENTIALS_JSON 环境变量传入完整的 JSON 数组
30
- # 2. 单凭据模式:通过单独的环境变量(向后兼容)
31
-
32
- if [ -n "${CREDENTIALS_JSON}" ]; then
33
- # 多凭据模式:直接使用 CREDENTIALS_JSON
34
- echo "${CREDENTIALS_JSON}" > /app/config/credentials.json
35
- echo "Using multi-credential mode from CREDENTIALS_JSON"
36
- else
37
- # 单凭据模式
38
- if [ "${AUTH_METHOD}" = "idc" ]; then
39
- cat > /app/config/credentials.json << EOF
40
- {
41
- "refreshToken": "${REFRESH_TOKEN}",
42
- "expiresAt": "${EXPIRES_AT:-2020-01-01T00:00:00.000Z}",
43
- "authMethod": "idc",
44
- "clientId": "${CLIENT_ID}",
45
- "clientSecret": "${CLIENT_SECRET}"
46
- }
47
- EOF
48
- else
49
- cat > /app/config/credentials.json << EOF
50
- {
51
- "refreshToken": "${REFRESH_TOKEN}",
52
- "expiresAt": "${EXPIRES_AT:-2020-01-01T00:00:00.000Z}",
53
- "authMethod": "${AUTH_METHOD:-social}"
54
- }
55
- EOF
56
- fi
57
- echo "Using single-credential mode"
58
- fi
59
-
60
- echo "Starting kiro-rs..."
61
- exec /app/kiro-rs -c /app/config/config.json --credentials /app/config/credentials.json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/admin/error.rs DELETED
@@ -1,64 +0,0 @@
1
- //! Admin API 错误类型定义
2
-
3
- use std::fmt;
4
-
5
- use axum::http::StatusCode;
6
-
7
- use super::types::AdminErrorResponse;
8
-
9
- /// Admin 服务错误类型
10
- #[derive(Debug)]
11
- pub enum AdminServiceError {
12
- /// 凭据不存在
13
- NotFound { id: u64 },
14
-
15
- /// 上游服务调用失败(网络、API 错误等)
16
- UpstreamError(String),
17
-
18
- /// 内部状态错误
19
- InternalError(String),
20
-
21
- /// 凭据无效(验证失败)
22
- InvalidCredential(String),
23
- }
24
-
25
- impl fmt::Display for AdminServiceError {
26
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27
- match self {
28
- AdminServiceError::NotFound { id } => {
29
- write!(f, "凭据不存在: {}", id)
30
- }
31
- AdminServiceError::UpstreamError(msg) => write!(f, "上游服务错误: {}", msg),
32
- AdminServiceError::InternalError(msg) => write!(f, "内部错误: {}", msg),
33
- AdminServiceError::InvalidCredential(msg) => write!(f, "凭据无效: {}", msg),
34
- }
35
- }
36
- }
37
-
38
- impl std::error::Error for AdminServiceError {}
39
-
40
- impl AdminServiceError {
41
- /// 获取对应的 HTTP 状态码
42
- pub fn status_code(&self) -> StatusCode {
43
- match self {
44
- AdminServiceError::NotFound { .. } => StatusCode::NOT_FOUND,
45
- AdminServiceError::UpstreamError(_) => StatusCode::BAD_GATEWAY,
46
- AdminServiceError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
47
- AdminServiceError::InvalidCredential(_) => StatusCode::BAD_REQUEST,
48
- }
49
- }
50
-
51
- /// 转换为 API 错误响应
52
- pub fn into_response(self) -> AdminErrorResponse {
53
- match &self {
54
- AdminServiceError::NotFound { .. } => AdminErrorResponse::not_found(self.to_string()),
55
- AdminServiceError::UpstreamError(_) => AdminErrorResponse::api_error(self.to_string()),
56
- AdminServiceError::InternalError(_) => {
57
- AdminErrorResponse::internal_error(self.to_string())
58
- }
59
- AdminServiceError::InvalidCredential(_) => {
60
- AdminErrorResponse::invalid_request(self.to_string())
61
- }
62
- }
63
- }
64
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/admin/handlers.rs DELETED
@@ -1,104 +0,0 @@
1
- //! Admin API HTTP 处理器
2
-
3
- use axum::{
4
- Json,
5
- extract::{Path, State},
6
- response::IntoResponse,
7
- };
8
-
9
- use super::{
10
- middleware::AdminState,
11
- types::{AddCredentialRequest, SetDisabledRequest, SetPriorityRequest, SuccessResponse},
12
- };
13
-
14
- /// GET /api/admin/credentials
15
- /// 获取所有凭据状态
16
- pub async fn get_all_credentials(State(state): State<AdminState>) -> impl IntoResponse {
17
- let response = state.service.get_all_credentials();
18
- Json(response)
19
- }
20
-
21
- /// POST /api/admin/credentials/:id/disabled
22
- /// 设置凭据禁用状态
23
- pub async fn set_credential_disabled(
24
- State(state): State<AdminState>,
25
- Path(id): Path<u64>,
26
- Json(payload): Json<SetDisabledRequest>,
27
- ) -> impl IntoResponse {
28
- match state.service.set_disabled(id, payload.disabled) {
29
- Ok(_) => {
30
- let action = if payload.disabled { "禁用" } else { "启用" };
31
- Json(SuccessResponse::new(format!("凭据 #{} 已{}", id, action))).into_response()
32
- }
33
- Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
34
- }
35
- }
36
-
37
- /// POST /api/admin/credentials/:id/priority
38
- /// 设置凭据优先级
39
- pub async fn set_credential_priority(
40
- State(state): State<AdminState>,
41
- Path(id): Path<u64>,
42
- Json(payload): Json<SetPriorityRequest>,
43
- ) -> impl IntoResponse {
44
- match state.service.set_priority(id, payload.priority) {
45
- Ok(_) => Json(SuccessResponse::new(format!(
46
- "凭据 #{} 优先级已设置为 {}",
47
- id, payload.priority
48
- )))
49
- .into_response(),
50
- Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
51
- }
52
- }
53
-
54
- /// POST /api/admin/credentials/:id/reset
55
- /// 重置失败计数并重新启用
56
- pub async fn reset_failure_count(
57
- State(state): State<AdminState>,
58
- Path(id): Path<u64>,
59
- ) -> impl IntoResponse {
60
- match state.service.reset_and_enable(id) {
61
- Ok(_) => Json(SuccessResponse::new(format!(
62
- "凭据 #{} 失败计数已重置并重新启用",
63
- id
64
- )))
65
- .into_response(),
66
- Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
67
- }
68
- }
69
-
70
- /// GET /api/admin/credentials/:id/balance
71
- /// 获取指定凭据的余额
72
- pub async fn get_credential_balance(
73
- State(state): State<AdminState>,
74
- Path(id): Path<u64>,
75
- ) -> impl IntoResponse {
76
- match state.service.get_balance(id).await {
77
- Ok(response) => Json(response).into_response(),
78
- Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
79
- }
80
- }
81
-
82
- /// POST /api/admin/credentials
83
- /// 添加新凭据
84
- pub async fn add_credential(
85
- State(state): State<AdminState>,
86
- Json(payload): Json<AddCredentialRequest>,
87
- ) -> impl IntoResponse {
88
- match state.service.add_credential(payload).await {
89
- Ok(response) => Json(response).into_response(),
90
- Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
91
- }
92
- }
93
-
94
- /// DELETE /api/admin/credentials/:id
95
- /// 删除凭据
96
- pub async fn delete_credential(
97
- State(state): State<AdminState>,
98
- Path(id): Path<u64>,
99
- ) -> impl IntoResponse {
100
- match state.service.delete_credential(id) {
101
- Ok(_) => Json(SuccessResponse::new(format!("凭据 #{} 已删除", id))).into_response(),
102
- Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
103
- }
104
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/admin/middleware.rs DELETED
@@ -1,50 +0,0 @@
1
- //! Admin API 中间件
2
-
3
- use std::sync::Arc;
4
-
5
- use axum::{
6
- body::Body,
7
- extract::State,
8
- http::{Request, StatusCode},
9
- middleware::Next,
10
- response::{IntoResponse, Json, Response},
11
- };
12
-
13
- use super::service::AdminService;
14
- use super::types::AdminErrorResponse;
15
- use crate::common::auth;
16
-
17
- /// Admin API 共享状态
18
- #[derive(Clone)]
19
- pub struct AdminState {
20
- /// Admin API 密钥
21
- pub admin_api_key: String,
22
- /// Admin 服务
23
- pub service: Arc<AdminService>,
24
- }
25
-
26
- impl AdminState {
27
- pub fn new(admin_api_key: impl Into<String>, service: AdminService) -> Self {
28
- Self {
29
- admin_api_key: admin_api_key.into(),
30
- service: Arc::new(service),
31
- }
32
- }
33
- }
34
-
35
- /// Admin API 认证中间件
36
- pub async fn admin_auth_middleware(
37
- State(state): State<AdminState>,
38
- request: Request<Body>,
39
- next: Next,
40
- ) -> Response {
41
- let api_key = auth::extract_api_key(&request);
42
-
43
- match api_key {
44
- Some(key) if auth::constant_time_eq(&key, &state.admin_api_key) => next.run(request).await,
45
- _ => {
46
- let error = AdminErrorResponse::authentication_error();
47
- (StatusCode::UNAUTHORIZED, Json(error)).into_response()
48
- }
49
- }
50
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/admin/mod.rs DELETED
@@ -1,28 +0,0 @@
1
- //! Admin API 模块
2
- //!
3
- //! 提供凭据管理和监控功能的 HTTP API
4
- //!
5
- //! # 功能
6
- //! - 查询所有凭据状态
7
- //! - 启用/禁用凭据
8
- //! - 修改凭据优先级
9
- //! - 重置失败计数
10
- //! - 查询凭据余额
11
- //!
12
- //! # 使用
13
- //! ```ignore
14
- //! let admin_service = AdminService::new(token_manager.clone());
15
- //! let admin_state = AdminState::new(admin_api_key, admin_service);
16
- //! let admin_router = create_admin_router(admin_state);
17
- //! ```
18
-
19
- mod error;
20
- mod handlers;
21
- mod middleware;
22
- mod router;
23
- mod service;
24
- pub mod types;
25
-
26
- pub use middleware::AdminState;
27
- pub use router::create_admin_router;
28
- pub use service::AdminService;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/admin/router.rs DELETED
@@ -1,47 +0,0 @@
1
- //! Admin API 路由配置
2
-
3
- use axum::{
4
- Router, middleware,
5
- routing::{delete, get, post},
6
- };
7
-
8
- use super::{
9
- handlers::{
10
- add_credential, delete_credential, get_all_credentials, get_credential_balance,
11
- reset_failure_count, set_credential_disabled, set_credential_priority,
12
- },
13
- middleware::{AdminState, admin_auth_middleware},
14
- };
15
-
16
- /// 创建 Admin API 路由
17
- ///
18
- /// # 端点
19
- /// - `GET /credentials` - 获取所有凭据状态
20
- /// - `POST /credentials` - 添加新凭据
21
- /// - `DELETE /credentials/:id` - 删除凭据
22
- /// - `POST /credentials/:id/disabled` - 设置凭据禁用状态
23
- /// - `POST /credentials/:id/priority` - 设置凭据优先级
24
- /// - `POST /credentials/:id/reset` - 重置失败计数
25
- /// - `GET /credentials/:id/balance` - 获取凭据余额
26
- ///
27
- /// # 认证
28
- /// 需要 Admin API Key 认证,支持:
29
- /// - `x-api-key` header
30
- /// - `Authorization: Bearer <token>` header
31
- pub fn create_admin_router(state: AdminState) -> Router {
32
- Router::new()
33
- .route(
34
- "/credentials",
35
- get(get_all_credentials).post(add_credential),
36
- )
37
- .route("/credentials/{id}", delete(delete_credential))
38
- .route("/credentials/{id}/disabled", post(set_credential_disabled))
39
- .route("/credentials/{id}/priority", post(set_credential_priority))
40
- .route("/credentials/{id}/reset", post(reset_failure_count))
41
- .route("/credentials/{id}/balance", get(get_credential_balance))
42
- .layer(middleware::from_fn_with_state(
43
- state.clone(),
44
- admin_auth_middleware,
45
- ))
46
- .with_state(state)
47
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/admin/service.rs DELETED
@@ -1,234 +0,0 @@
1
- //! Admin API 业务逻辑服务
2
-
3
- use std::sync::Arc;
4
-
5
- use crate::kiro::model::credentials::KiroCredentials;
6
- use crate::kiro::token_manager::MultiTokenManager;
7
-
8
- use super::error::AdminServiceError;
9
- use super::types::{
10
- AddCredentialRequest, AddCredentialResponse, BalanceResponse, CredentialStatusItem,
11
- CredentialsStatusResponse,
12
- };
13
-
14
- /// Admin 服务
15
- ///
16
- /// 封装所有 Admin API 的业务逻辑
17
- pub struct AdminService {
18
- token_manager: Arc<MultiTokenManager>,
19
- }
20
-
21
- impl AdminService {
22
- pub fn new(token_manager: Arc<MultiTokenManager>) -> Self {
23
- Self { token_manager }
24
- }
25
-
26
- /// 获取所有凭据状态
27
- pub fn get_all_credentials(&self) -> CredentialsStatusResponse {
28
- let snapshot = self.token_manager.snapshot();
29
-
30
- let mut credentials: Vec<CredentialStatusItem> = snapshot
31
- .entries
32
- .into_iter()
33
- .map(|entry| CredentialStatusItem {
34
- id: entry.id,
35
- priority: entry.priority,
36
- disabled: entry.disabled,
37
- failure_count: entry.failure_count,
38
- is_current: entry.id == snapshot.current_id,
39
- expires_at: entry.expires_at,
40
- auth_method: entry.auth_method,
41
- has_profile_arn: entry.has_profile_arn,
42
- })
43
- .collect();
44
-
45
- // 按优先级排序(数字越小优先级越高)
46
- credentials.sort_by_key(|c| c.priority);
47
-
48
- CredentialsStatusResponse {
49
- total: snapshot.total,
50
- available: snapshot.available,
51
- current_id: snapshot.current_id,
52
- credentials,
53
- }
54
- }
55
-
56
- /// 设置凭据禁用状态
57
- pub fn set_disabled(&self, id: u64, disabled: bool) -> Result<(), AdminServiceError> {
58
- // 先获取当前凭据 ID,用于判断是否需要切换
59
- let snapshot = self.token_manager.snapshot();
60
- let current_id = snapshot.current_id;
61
-
62
- self.token_manager
63
- .set_disabled(id, disabled)
64
- .map_err(|e| self.classify_error(e, id))?;
65
-
66
- // 只有禁用的是当前凭据时才尝试切换到下一个
67
- if disabled && id == current_id {
68
- let _ = self.token_manager.switch_to_next();
69
- }
70
- Ok(())
71
- }
72
-
73
- /// 设置凭据优先级
74
- pub fn set_priority(&self, id: u64, priority: u32) -> Result<(), AdminServiceError> {
75
- self.token_manager
76
- .set_priority(id, priority)
77
- .map_err(|e| self.classify_error(e, id))
78
- }
79
-
80
- /// 重置失败计数并重新启用
81
- pub fn reset_and_enable(&self, id: u64) -> Result<(), AdminServiceError> {
82
- self.token_manager
83
- .reset_and_enable(id)
84
- .map_err(|e| self.classify_error(e, id))
85
- }
86
-
87
- /// 获取凭据余额
88
- pub async fn get_balance(&self, id: u64) -> Result<BalanceResponse, AdminServiceError> {
89
- let usage = self
90
- .token_manager
91
- .get_usage_limits_for(id)
92
- .await
93
- .map_err(|e| self.classify_balance_error(e, id))?;
94
-
95
- let current_usage = usage.current_usage();
96
- let usage_limit = usage.usage_limit();
97
- let remaining = (usage_limit - current_usage).max(0.0);
98
- let usage_percentage = if usage_limit > 0.0 {
99
- (current_usage / usage_limit * 100.0).min(100.0)
100
- } else {
101
- 0.0
102
- };
103
-
104
- Ok(BalanceResponse {
105
- id,
106
- subscription_title: usage.subscription_title().map(|s| s.to_string()),
107
- current_usage,
108
- usage_limit,
109
- remaining,
110
- usage_percentage,
111
- next_reset_at: usage.next_date_reset,
112
- })
113
- }
114
-
115
- /// 添加新凭据
116
- pub async fn add_credential(
117
- &self,
118
- req: AddCredentialRequest,
119
- ) -> Result<AddCredentialResponse, AdminServiceError> {
120
- // 构建凭据对象
121
- let new_cred = KiroCredentials {
122
- id: None,
123
- access_token: None,
124
- refresh_token: Some(req.refresh_token),
125
- profile_arn: None,
126
- expires_at: None,
127
- auth_method: Some(req.auth_method),
128
- client_id: req.client_id,
129
- client_secret: req.client_secret,
130
- priority: req.priority,
131
- region: req.region,
132
- machine_id: req.machine_id,
133
- };
134
-
135
- // 调用 token_manager 添加凭据
136
- let credential_id = self
137
- .token_manager
138
- .add_credential(new_cred)
139
- .await
140
- .map_err(|e| self.classify_add_error(e))?;
141
-
142
- Ok(AddCredentialResponse {
143
- success: true,
144
- message: format!("凭据添加成功,ID: {}", credential_id),
145
- credential_id,
146
- })
147
- }
148
-
149
- /// 删除凭据
150
- pub fn delete_credential(&self, id: u64) -> Result<(), AdminServiceError> {
151
- self.token_manager
152
- .delete_credential(id)
153
- .map_err(|e| self.classify_delete_error(e, id))
154
- }
155
-
156
- /// 分类简单操作错误(set_disabled, set_priority, reset_and_enable)
157
- fn classify_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
158
- let msg = e.to_string();
159
- if msg.contains("不存在") {
160
- AdminServiceError::NotFound { id }
161
- } else {
162
- AdminServiceError::InternalError(msg)
163
- }
164
- }
165
-
166
- /// 分类余额查询错误(可能涉及上游 API 调用)
167
- fn classify_balance_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
168
- let msg = e.to_string();
169
-
170
- // 1. 凭据不存在
171
- if msg.contains("不存在") {
172
- return AdminServiceError::NotFound { id };
173
- }
174
-
175
- // 2. 上游服务错误特征:HTTP 响应错误或网络错误
176
- let is_upstream_error =
177
- // HTTP 响应错误(来自 refresh_*_token 的错误消息)
178
- msg.contains("凭证已过期或无效") ||
179
- msg.contains("权限不足") ||
180
- msg.contains("已被限流") ||
181
- msg.contains("服务器错误") ||
182
- msg.contains("Token 刷新失败") ||
183
- msg.contains("暂时不可用") ||
184
- // 网络错误(reqwest 错误)
185
- msg.contains("error trying to connect") ||
186
- msg.contains("connection") ||
187
- msg.contains("timeout") ||
188
- msg.contains("timed out");
189
-
190
- if is_upstream_error {
191
- AdminServiceError::UpstreamError(msg)
192
- } else {
193
- // 3. 默认归类为内部错误(本地验证失败、配置错误等)
194
- // 包括:缺少 refreshToken、refreshToken 已被截断、无法生成 machineId 等
195
- AdminServiceError::InternalError(msg)
196
- }
197
- }
198
-
199
- /// 分类添加凭据错误
200
- fn classify_add_error(&self, e: anyhow::Error) -> AdminServiceError {
201
- let msg = e.to_string();
202
-
203
- // 凭据验证失败(refreshToken 无效、格式错误等)
204
- let is_invalid_credential = msg.contains("缺少 refreshToken")
205
- || msg.contains("refreshToken 为空")
206
- || msg.contains("refreshToken 已被截断")
207
- || msg.contains("凭证已过期或无效")
208
- || msg.contains("权限不足")
209
- || msg.contains("已被限流");
210
-
211
- if is_invalid_credential {
212
- AdminServiceError::InvalidCredential(msg)
213
- } else if msg.contains("error trying to connect")
214
- || msg.contains("connection")
215
- || msg.contains("timeout")
216
- {
217
- AdminServiceError::UpstreamError(msg)
218
- } else {
219
- AdminServiceError::InternalError(msg)
220
- }
221
- }
222
-
223
- /// 分类删除凭据错误
224
- fn classify_delete_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
225
- let msg = e.to_string();
226
- if msg.contains("不存在") {
227
- AdminServiceError::NotFound { id }
228
- } else if msg.contains("只能删除已禁用的凭据") {
229
- AdminServiceError::InvalidCredential(msg)
230
- } else {
231
- AdminServiceError::InternalError(msg)
232
- }
233
- }
234
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/admin/types.rs DELETED
@@ -1,187 +0,0 @@
1
- //! Admin API 类型定义
2
-
3
- use serde::{Deserialize, Serialize};
4
-
5
- // ============ 凭据状态 ============
6
-
7
- /// 所有凭据状态响应
8
- #[derive(Debug, Serialize)]
9
- #[serde(rename_all = "camelCase")]
10
- pub struct CredentialsStatusResponse {
11
- /// 凭据总数
12
- pub total: usize,
13
- /// 可用凭据数量(未禁用)
14
- pub available: usize,
15
- /// 当前活跃凭据 ID
16
- pub current_id: u64,
17
- /// 各凭据状态列表
18
- pub credentials: Vec<CredentialStatusItem>,
19
- }
20
-
21
- /// 单个凭据的状态信息
22
- #[derive(Debug, Serialize)]
23
- #[serde(rename_all = "camelCase")]
24
- pub struct CredentialStatusItem {
25
- /// 凭据唯一 ID
26
- pub id: u64,
27
- /// 优先级(数字越小优先级越高)
28
- pub priority: u32,
29
- /// 是否被禁用
30
- pub disabled: bool,
31
- /// 连续失败次数
32
- pub failure_count: u32,
33
- /// 是否为当前活跃凭据
34
- pub is_current: bool,
35
- /// Token 过期时间(RFC3339 格式)
36
- pub expires_at: Option<String>,
37
- /// 认证方式
38
- pub auth_method: Option<String>,
39
- /// 是否有 Profile ARN
40
- pub has_profile_arn: bool,
41
- }
42
-
43
- // ============ 操作请求 ============
44
-
45
- /// 启用/禁用凭据请求
46
- #[derive(Debug, Deserialize)]
47
- #[serde(rename_all = "camelCase")]
48
- pub struct SetDisabledRequest {
49
- /// 是否禁用
50
- pub disabled: bool,
51
- }
52
-
53
- /// 修改优先级请求
54
- #[derive(Debug, Deserialize)]
55
- #[serde(rename_all = "camelCase")]
56
- pub struct SetPriorityRequest {
57
- /// 新优先级值
58
- pub priority: u32,
59
- }
60
-
61
- /// 添加凭据请求
62
- #[derive(Debug, Deserialize)]
63
- #[serde(rename_all = "camelCase")]
64
- pub struct AddCredentialRequest {
65
- /// 刷新令牌(必填)
66
- pub refresh_token: String,
67
-
68
- /// 认证方式(可选,默认 social)
69
- #[serde(default = "default_auth_method")]
70
- pub auth_method: String,
71
-
72
- /// OIDC Client ID(IdC 认证需要)
73
- pub client_id: Option<String>,
74
-
75
- /// OIDC Client Secret(IdC 认证需要)
76
- pub client_secret: Option<String>,
77
-
78
- /// 优先级(可选,默认 0)
79
- #[serde(default)]
80
- pub priority: u32,
81
-
82
- /// 凭据级 Region 配置(用于 OIDC token 刷新)
83
- /// 未配置时回退到 config.json 的全局 region
84
- pub region: Option<String>,
85
-
86
- /// 凭据级 Machine ID(可选,64 位字符串)
87
- /// 未配置时回退到 config.json 的 machineId
88
- pub machine_id: Option<String>,
89
- }
90
-
91
- fn default_auth_method() -> String {
92
- "social".to_string()
93
- }
94
-
95
- /// 添加凭据成功响应
96
- #[derive(Debug, Serialize)]
97
- #[serde(rename_all = "camelCase")]
98
- pub struct AddCredentialResponse {
99
- pub success: bool,
100
- pub message: String,
101
- /// 新添加的凭据 ID
102
- pub credential_id: u64,
103
- }
104
-
105
- // ============ 余额查询 ============
106
-
107
- /// 余额查询响应
108
- #[derive(Debug, Serialize)]
109
- #[serde(rename_all = "camelCase")]
110
- pub struct BalanceResponse {
111
- /// 凭据 ID
112
- pub id: u64,
113
- /// 订阅类型
114
- pub subscription_title: Option<String>,
115
- /// 当前使用量
116
- pub current_usage: f64,
117
- /// 使用限额
118
- pub usage_limit: f64,
119
- /// 剩余额度
120
- pub remaining: f64,
121
- /// 使用百分比
122
- pub usage_percentage: f64,
123
- /// 下次重置时间(Unix 时间戳)
124
- pub next_reset_at: Option<f64>,
125
- }
126
-
127
- // ============ 通用响应 ============
128
-
129
- /// 操作成功响应
130
- #[derive(Debug, Serialize)]
131
- pub struct SuccessResponse {
132
- pub success: bool,
133
- pub message: String,
134
- }
135
-
136
- impl SuccessResponse {
137
- pub fn new(message: impl Into<String>) -> Self {
138
- Self {
139
- success: true,
140
- message: message.into(),
141
- }
142
- }
143
- }
144
-
145
- /// 错误响应
146
- #[derive(Debug, Serialize)]
147
- pub struct AdminErrorResponse {
148
- pub error: AdminError,
149
- }
150
-
151
- #[derive(Debug, Serialize)]
152
- pub struct AdminError {
153
- #[serde(rename = "type")]
154
- pub error_type: String,
155
- pub message: String,
156
- }
157
-
158
- impl AdminErrorResponse {
159
- pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
160
- Self {
161
- error: AdminError {
162
- error_type: error_type.into(),
163
- message: message.into(),
164
- },
165
- }
166
- }
167
-
168
- pub fn invalid_request(message: impl Into<String>) -> Self {
169
- Self::new("invalid_request", message)
170
- }
171
-
172
- pub fn authentication_error() -> Self {
173
- Self::new("authentication_error", "Invalid or missing admin API key")
174
- }
175
-
176
- pub fn not_found(message: impl Into<String>) -> Self {
177
- Self::new("not_found", message)
178
- }
179
-
180
- pub fn api_error(message: impl Into<String>) -> Self {
181
- Self::new("api_error", message)
182
- }
183
-
184
- pub fn internal_error(message: impl Into<String>) -> Self {
185
- Self::new("internal_error", message)
186
- }
187
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/admin_ui/mod.rs DELETED
@@ -1,7 +0,0 @@
1
- //! Admin UI 静态文件服务模块
2
- //!
3
- //! 使用 rust-embed 嵌入前端构建产物
4
-
5
- mod router;
6
-
7
- pub use router::create_admin_ui_router;
 
 
 
 
 
 
 
 
src/admin_ui/router.rs DELETED
@@ -1,109 +0,0 @@
1
- //! Admin UI 路由配置
2
-
3
- use axum::{
4
- Router,
5
- body::Body,
6
- http::{Response, StatusCode, Uri, header},
7
- response::IntoResponse,
8
- routing::get,
9
- };
10
- use rust_embed::Embed;
11
-
12
- /// 嵌入前端构建产物
13
- #[derive(Embed)]
14
- #[folder = "admin-ui/dist"]
15
- struct Asset;
16
-
17
- /// 创建 Admin UI 路由
18
- pub fn create_admin_ui_router() -> Router {
19
- Router::new()
20
- .route("/", get(index_handler))
21
- .route("/{*file}", get(static_handler))
22
- }
23
-
24
- /// 处理首页请求
25
- async fn index_handler() -> impl IntoResponse {
26
- serve_index()
27
- }
28
-
29
- /// 处理静态文件请求
30
- async fn static_handler(uri: Uri) -> impl IntoResponse {
31
- let path = uri.path().trim_start_matches('/');
32
-
33
- // 安全检查:拒绝包含 .. 的路径
34
- if path.contains("..") {
35
- return Response::builder()
36
- .status(StatusCode::BAD_REQUEST)
37
- .body(Body::from("Invalid path"))
38
- .expect("Failed to build response");
39
- }
40
-
41
- // 尝试获取请求的文件
42
- if let Some(content) = Asset::get(path) {
43
- let mime = mime_guess::from_path(path)
44
- .first_or_octet_stream()
45
- .to_string();
46
-
47
- // 根据文件类型设置不同的缓存策略
48
- let cache_control = get_cache_control(path);
49
-
50
- return Response::builder()
51
- .status(StatusCode::OK)
52
- .header(header::CONTENT_TYPE, mime)
53
- .header(header::CACHE_CONTROL, cache_control)
54
- .body(Body::from(content.data.into_owned()))
55
- .expect("Failed to build response");
56
- }
57
-
58
- // SPA fallback: 如果文件不存在且不是资源文件,返回 index.html
59
- if !is_asset_path(path) {
60
- return serve_index();
61
- }
62
-
63
- // 404
64
- Response::builder()
65
- .status(StatusCode::NOT_FOUND)
66
- .body(Body::from("Not found"))
67
- .expect("Failed to build response")
68
- }
69
-
70
- /// 提供 index.html
71
- fn serve_index() -> Response<Body> {
72
- match Asset::get("index.html") {
73
- Some(content) => Response::builder()
74
- .status(StatusCode::OK)
75
- .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
76
- .header(header::CACHE_CONTROL, "no-cache")
77
- .body(Body::from(content.data.into_owned()))
78
- .expect("Failed to build response"),
79
- None => Response::builder()
80
- .status(StatusCode::NOT_FOUND)
81
- .body(Body::from(
82
- "Admin UI not built. Run 'pnpm build' in admin-ui directory.",
83
- ))
84
- .expect("Failed to build response"),
85
- }
86
- }
87
-
88
- /// 根据文件类型返回合适的缓存策略
89
- fn get_cache_control(path: &str) -> &'static str {
90
- if path.ends_with(".html") {
91
- // HTML 文件不缓存,确保用户获取最新版本
92
- "no-cache"
93
- } else if path.starts_with("assets/") {
94
- // assets/ 目录下的文件带有内容哈希,可以长期缓存
95
- "public, max-age=31536000, immutable"
96
- } else {
97
- // 其他文件(如 favicon)使用较短的缓存
98
- "public, max-age=3600"
99
- }
100
- }
101
-
102
- /// 判断是否为资源文件路径(有扩展名的文件)
103
- fn is_asset_path(path: &str) -> bool {
104
- // 检查最后一个路径段是否包含扩展名
105
- path.rsplit('/')
106
- .next()
107
- .map(|filename| filename.contains('.'))
108
- .unwrap_or(false)
109
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/anthropic/converter.rs DELETED
@@ -1,1118 +0,0 @@
1
- //! Anthropic → Kiro 协议转换器
2
- //!
3
- //! 负责将 Anthropic API 请求格式转换为 Kiro API 请求格式
4
-
5
- use uuid::Uuid;
6
-
7
- use crate::kiro::model::requests::conversation::{
8
- AssistantMessage, ConversationState, CurrentMessage, HistoryAssistantMessage,
9
- HistoryUserMessage, KiroImage, Message, UserInputMessage, UserInputMessageContext, UserMessage,
10
- };
11
- use crate::kiro::model::requests::tool::{
12
- InputSchema, Tool, ToolResult, ToolSpecification, ToolUseEntry,
13
- };
14
-
15
- use super::types::{ContentBlock, MessagesRequest, Thinking};
16
-
17
- /// 模型映射:将 Anthropic 模型名映射到 Kiro 模型 ID
18
- ///
19
- /// 按照用户要求:
20
- /// - 所有 sonnet → claude-sonnet-4.5
21
- /// - 所有 opus → claude-opus-4.5
22
- /// - 所有 haiku → claude-haiku-4.5
23
- pub fn map_model(model: &str) -> Option<String> {
24
- let model_lower = model.to_lowercase();
25
-
26
- if model_lower.contains("sonnet") {
27
- Some("claude-sonnet-4.5".to_string())
28
- } else if model_lower.contains("opus") {
29
- Some("claude-opus-4.5".to_string())
30
- } else if model_lower.contains("haiku") {
31
- Some("claude-haiku-4.5".to_string())
32
- } else {
33
- None
34
- }
35
- }
36
-
37
- /// 转换结果
38
- #[derive(Debug)]
39
- pub struct ConversionResult {
40
- /// 转换后的 Kiro 请求
41
- pub conversation_state: ConversationState,
42
- }
43
-
44
- /// 转换错误
45
- #[derive(Debug)]
46
- pub enum ConversionError {
47
- UnsupportedModel(String),
48
- EmptyMessages,
49
- }
50
-
51
- impl std::fmt::Display for ConversionError {
52
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53
- match self {
54
- ConversionError::UnsupportedModel(model) => write!(f, "模型不支持: {}", model),
55
- ConversionError::EmptyMessages => write!(f, "消息列表为空"),
56
- }
57
- }
58
- }
59
-
60
- impl std::error::Error for ConversionError {}
61
-
62
- /// 从 metadata.user_id 中提取 session UUID
63
- ///
64
- /// user_id 格式: user_xxx_account__session_0b4445e1-f5be-49e1-87ce-62bbc28ad705
65
- /// 提取 session_ 后面的 UUID 作为 conversationId
66
- fn extract_session_id(user_id: &str) -> Option<String> {
67
- // 查找 "session_" 后面的内容
68
- if let Some(pos) = user_id.find("session_") {
69
- let session_part = &user_id[pos + 8..]; // "session_" 长度为 8
70
- // session_part 应该是 UUID 格式: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
71
- // 验证是否是有效的 UUID 格式(36 字符,包含 4 个连字符)
72
- if session_part.len() >= 36 {
73
- let uuid_str = &session_part[..36];
74
- // 简单验证 UUID 格式
75
- if uuid_str.chars().filter(|c| *c == '-').count() == 4 {
76
- return Some(uuid_str.to_string());
77
- }
78
- }
79
- }
80
- None
81
- }
82
-
83
- /// 收集历史消息中使用的所有工具名称
84
- fn collect_history_tool_names(history: &[Message]) -> Vec<String> {
85
- let mut tool_names = Vec::new();
86
-
87
- for msg in history {
88
- if let Message::Assistant(assistant_msg) = msg {
89
- if let Some(ref tool_uses) = assistant_msg.assistant_response_message.tool_uses {
90
- for tool_use in tool_uses {
91
- if !tool_names.contains(&tool_use.name) {
92
- tool_names.push(tool_use.name.clone());
93
- }
94
- }
95
- }
96
- }
97
- }
98
-
99
- tool_names
100
- }
101
-
102
- /// 为历史中使用但不在 tools 列表中的工具创建占位符定义
103
- /// Kiro API 要求:历史消息中引用的工具必须在 currentMessage.tools 中有定义
104
- fn create_placeholder_tool(name: &str) -> Tool {
105
- Tool {
106
- tool_specification: ToolSpecification {
107
- name: name.to_string(),
108
- description: "Tool used in conversation history".to_string(),
109
- input_schema: InputSchema::from_json(serde_json::json!({
110
- "$schema": "http://json-schema.org/draft-07/schema#",
111
- "type": "object",
112
- "properties": {},
113
- "required": [],
114
- "additionalProperties": true
115
- })),
116
- },
117
- }
118
- }
119
-
120
- /// 将 Anthropic 请求转换为 Kiro 请求
121
- pub fn convert_request(req: &MessagesRequest) -> Result<ConversionResult, ConversionError> {
122
- // 1. 映射模型
123
- let model_id = map_model(&req.model)
124
- .ok_or_else(|| ConversionError::UnsupportedModel(req.model.clone()))?;
125
-
126
- // 2. 检查消息列表
127
- if req.messages.is_empty() {
128
- return Err(ConversionError::EmptyMessages);
129
- }
130
-
131
- // 3. 生成会话 ID 和代理 ID
132
- // 优先从 metadata.user_id 中提取 session UUID 作为 conversationId
133
- let conversation_id = req
134
- .metadata
135
- .as_ref()
136
- .and_then(|m| m.user_id.as_ref())
137
- .and_then(|user_id| extract_session_id(user_id))
138
- .unwrap_or_else(|| Uuid::new_v4().to_string());
139
- let agent_continuation_id = Uuid::new_v4().to_string();
140
-
141
- // 4. 确定触发类型
142
- let chat_trigger_type = determine_chat_trigger_type(req);
143
-
144
- // 5. 处理最后一条消息作为 current_message
145
- let last_message = req.messages.last().unwrap();
146
- let (text_content, images, tool_results) = process_message_content(&last_message.content)?;
147
-
148
- // 6. 转换工具定义
149
- let mut tools = convert_tools(&req.tools);
150
-
151
- // 7. 构建历史消息(需要先构建,以便收集历史中使用的工具)
152
- let history = build_history(req, &model_id)?;
153
-
154
- // 8. 验证并过滤 tool_use/tool_result 配对
155
- // 移除孤立的 tool_result(没有对应的 tool_use)
156
- let validated_tool_results = validate_tool_pairing(&history, &tool_results);
157
-
158
- // 9. 收集历史中使用的工具名称,为缺失的工具生成占位符定义
159
- // Kiro API 要求:历史消息中引用的工具必须在 tools 列表中有定义
160
- // 注意:Kiro 匹配工具名称时忽略大小写,所以这里也需要忽略大小写比较
161
- let history_tool_names = collect_history_tool_names(&history);
162
- let existing_tool_names: std::collections::HashSet<_> = tools
163
- .iter()
164
- .map(|t| t.tool_specification.name.to_lowercase())
165
- .collect();
166
-
167
- for tool_name in history_tool_names {
168
- if !existing_tool_names.contains(&tool_name.to_lowercase()) {
169
- tools.push(create_placeholder_tool(&tool_name));
170
- }
171
- }
172
-
173
- // 10. 构建 UserInputMessageContext
174
- let mut context = UserInputMessageContext::new();
175
- if !tools.is_empty() {
176
- context = context.with_tools(tools);
177
- }
178
- if !validated_tool_results.is_empty() {
179
- context = context.with_tool_results(validated_tool_results);
180
- }
181
-
182
- // 11. 构建当前消息
183
- // 保留文本内容,即使有工具结果也不丢弃用户文本
184
- let content = text_content;
185
-
186
- let mut user_input = UserInputMessage::new(content, &model_id)
187
- .with_context(context)
188
- .with_origin("AI_EDITOR");
189
-
190
- if !images.is_empty() {
191
- user_input = user_input.with_images(images);
192
- }
193
-
194
- let current_message = CurrentMessage::new(user_input);
195
-
196
- // 12. 构建 ConversationState
197
- let conversation_state = ConversationState::new(conversation_id)
198
- .with_agent_continuation_id(agent_continuation_id)
199
- .with_agent_task_type("vibe")
200
- .with_chat_trigger_type(chat_trigger_type)
201
- .with_current_message(current_message)
202
- .with_history(history);
203
-
204
- Ok(ConversionResult { conversation_state })
205
- }
206
-
207
- /// 确定聊天触发类型
208
- /// "AUTO" 模式可能会导致 400 Bad Request 错误
209
- fn determine_chat_trigger_type(_req: &MessagesRequest) -> String {
210
- "MANUAL".to_string()
211
- }
212
-
213
- /// 处理消息内容,提取文本、图片和工具结果
214
- fn process_message_content(
215
- content: &serde_json::Value,
216
- ) -> Result<(String, Vec<KiroImage>, Vec<ToolResult>), ConversionError> {
217
- let mut text_parts = Vec::new();
218
- let mut images = Vec::new();
219
- let mut tool_results = Vec::new();
220
-
221
- match content {
222
- serde_json::Value::String(s) => {
223
- text_parts.push(s.clone());
224
- }
225
- serde_json::Value::Array(arr) => {
226
- for item in arr {
227
- if let Ok(block) = serde_json::from_value::<ContentBlock>(item.clone()) {
228
- match block.block_type.as_str() {
229
- "text" => {
230
- if let Some(text) = block.text {
231
- text_parts.push(text);
232
- }
233
- }
234
- "image" => {
235
- if let Some(source) = block.source {
236
- if let Some(format) = get_image_format(&source.media_type) {
237
- images.push(KiroImage::from_base64(format, source.data));
238
- }
239
- }
240
- }
241
- "tool_result" => {
242
- if let Some(tool_use_id) = block.tool_use_id {
243
- let result_content = extract_tool_result_content(&block.content);
244
- let is_error = block.is_error.unwrap_or(false);
245
-
246
- let mut result = if is_error {
247
- ToolResult::error(&tool_use_id, result_content)
248
- } else {
249
- ToolResult::success(&tool_use_id, result_content)
250
- };
251
- result.status =
252
- Some(if is_error { "error" } else { "success" }.to_string());
253
-
254
- tool_results.push(result);
255
- }
256
- }
257
- "tool_use" => {
258
- // tool_use 在 assistant 消息中处理,这里忽略
259
- }
260
- _ => {}
261
- }
262
- }
263
- }
264
- }
265
- _ => {}
266
- }
267
-
268
- Ok((text_parts.join("\n"), images, tool_results))
269
- }
270
-
271
- /// 从 media_type 获取图片格式
272
- fn get_image_format(media_type: &str) -> Option<String> {
273
- match media_type {
274
- "image/jpeg" => Some("jpeg".to_string()),
275
- "image/png" => Some("png".to_string()),
276
- "image/gif" => Some("gif".to_string()),
277
- "image/webp" => Some("webp".to_string()),
278
- _ => None,
279
- }
280
- }
281
-
282
- /// 提取工具结果内容
283
- fn extract_tool_result_content(content: &Option<serde_json::Value>) -> String {
284
- match content {
285
- Some(serde_json::Value::String(s)) => s.clone(),
286
- Some(serde_json::Value::Array(arr)) => {
287
- let mut parts = Vec::new();
288
- for item in arr {
289
- if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
290
- parts.push(text.to_string());
291
- }
292
- }
293
- parts.join("\n")
294
- }
295
- Some(v) => v.to_string(),
296
- None => String::new(),
297
- }
298
- }
299
-
300
- /// 验证并过滤 tool_use/tool_result 配对
301
- ///
302
- /// 收集所有 tool_use_id,验证 tool_result 是否匹配
303
- /// 静默跳过孤立的 tool_use 和 tool_result,输出警告日志
304
- ///
305
- /// # Arguments
306
- /// * `history` - 历史消息引用
307
- /// * `tool_results` - 当前消息中的 tool_result 列表
308
- ///
309
- /// # Returns
310
- /// 经过验证和过滤后的 tool_result 列表
311
- fn validate_tool_pairing(history: &[Message], tool_results: &[ToolResult]) -> Vec<ToolResult> {
312
- use std::collections::HashSet;
313
-
314
- // 1. 收集所有历史中的 tool_use_id
315
- let mut all_tool_use_ids: HashSet<String> = HashSet::new();
316
- // 2. 收集历史中已经有 tool_result 的 tool_use_id
317
- let mut history_tool_result_ids: HashSet<String> = HashSet::new();
318
-
319
- for msg in history {
320
- match msg {
321
- Message::Assistant(assistant_msg) => {
322
- if let Some(ref tool_uses) = assistant_msg.assistant_response_message.tool_uses {
323
- for tool_use in tool_uses {
324
- all_tool_use_ids.insert(tool_use.tool_use_id.clone());
325
- }
326
- }
327
- }
328
- Message::User(user_msg) => {
329
- // 收集历史 user 消息中的 tool_results
330
- for result in &user_msg.user_input_message.user_input_message_context.tool_results
331
- {
332
- history_tool_result_ids.insert(result.tool_use_id.clone());
333
- }
334
- }
335
- }
336
- }
337
-
338
- // 3. 计算真正未配对的 tool_use_ids(排除历史中已配对的)
339
- let mut unpaired_tool_use_ids: HashSet<String> = all_tool_use_ids
340
- .difference(&history_tool_result_ids)
341
- .cloned()
342
- .collect();
343
-
344
- // 4. 过滤并验证当前消息的 tool_results
345
- let mut filtered_results = Vec::new();
346
-
347
- for result in tool_results {
348
- if unpaired_tool_use_ids.contains(&result.tool_use_id) {
349
- // 配对成功
350
- filtered_results.push(result.clone());
351
- unpaired_tool_use_ids.remove(&result.tool_use_id);
352
- } else if all_tool_use_ids.contains(&result.tool_use_id) {
353
- // tool_use 存在但已经在历史中配对过了,这是重复的 tool_result
354
- tracing::warn!(
355
- "跳过重复的 tool_result:该 tool_use 已在历史中配对,tool_use_id={}",
356
- result.tool_use_id
357
- );
358
- } else {
359
- // 孤立 tool_result - 找不到对应的 tool_use
360
- tracing::warn!(
361
- "跳过孤立的 tool_result:找不到对应的 tool_use,tool_use_id={}",
362
- result.tool_use_id
363
- );
364
- }
365
- }
366
-
367
- // 5. 检测真正孤立的 tool_use(有 tool_use 但在历史和当前消息中都没有 tool_result)
368
- for orphaned_id in &unpaired_tool_use_ids {
369
- tracing::warn!(
370
- "检测到孤立的 tool_use:找不到对应的 tool_result,tool_use_id={}",
371
- orphaned_id
372
- );
373
- }
374
-
375
- filtered_results
376
- }
377
-
378
- /// 转换工具定义
379
- fn convert_tools(tools: &Option<Vec<super::types::Tool>>) -> Vec<Tool> {
380
- let Some(tools) = tools else {
381
- return Vec::new();
382
- };
383
-
384
- tools
385
- .iter()
386
- .map(|t| {
387
- let description = t.description.clone();
388
- // 限制描述长度为 10000 字符(安全截断 UTF-8,单次遍历)
389
- let description = match description.char_indices().nth(10000) {
390
- Some((idx, _)) => description[..idx].to_string(),
391
- None => description,
392
- };
393
-
394
- Tool {
395
- tool_specification: ToolSpecification {
396
- name: t.name.clone(),
397
- description,
398
- input_schema: InputSchema::from_json(serde_json::json!(t.input_schema)),
399
- },
400
- }
401
- })
402
- .collect()
403
- }
404
-
405
- /// 生成thinking标签前缀
406
- fn generate_thinking_prefix(thinking: &Option<Thinking>) -> Option<String> {
407
- if let Some(t) = thinking {
408
- if t.thinking_type == "enabled" {
409
- return Some(format!(
410
- "<thinking_mode>enabled</thinking_mode><max_thinking_length>{}</max_thinking_length>",
411
- t.budget_tokens
412
- ));
413
- }
414
- }
415
- None
416
- }
417
-
418
- /// 检查内容是否已包含thinking标签
419
- fn has_thinking_tags(content: &str) -> bool {
420
- content.contains("<thinking_mode>") || content.contains("<max_thinking_length>")
421
- }
422
-
423
- /// 构建历史消息
424
- fn build_history(req: &MessagesRequest, model_id: &str) -> Result<Vec<Message>, ConversionError> {
425
- let mut history = Vec::new();
426
-
427
- // 生成thinking前缀(如果需要)
428
- let thinking_prefix = generate_thinking_prefix(&req.thinking);
429
-
430
- // 1. 处理系统消息
431
- if let Some(ref system) = req.system {
432
- let system_content: String = system
433
- .iter()
434
- .map(|s| s.text.clone())
435
- .collect::<Vec<_>>()
436
- .join("\n");
437
-
438
- if !system_content.is_empty() {
439
- // 注入thinking标签到系统消息最前面(如果需要且不存在)
440
- let final_content = if let Some(ref prefix) = thinking_prefix {
441
- if !has_thinking_tags(&system_content) {
442
- format!("{}\n{}", prefix, system_content)
443
- } else {
444
- system_content
445
- }
446
- } else {
447
- system_content
448
- };
449
-
450
- // 系统消息作为 user + assistant 配对
451
- let user_msg = HistoryUserMessage::new(final_content, model_id);
452
- history.push(Message::User(user_msg));
453
-
454
- let assistant_msg = HistoryAssistantMessage::new("I will follow these instructions.");
455
- history.push(Message::Assistant(assistant_msg));
456
- }
457
- } else if let Some(ref prefix) = thinking_prefix {
458
- // 没有系统消息但有thinking配置,插入新的系统消息
459
- let user_msg = HistoryUserMessage::new(prefix.clone(), model_id);
460
- history.push(Message::User(user_msg));
461
-
462
- let assistant_msg = HistoryAssistantMessage::new("I will follow these instructions.");
463
- history.push(Message::Assistant(assistant_msg));
464
- }
465
-
466
- // 2. 处理常规消息历史
467
- // 最后一条消息作为 currentMessage,不加入历史
468
- let history_end_index = req.messages.len().saturating_sub(1);
469
-
470
- // 如果最后一条是 assistant,则包含在历史中
471
- let last_is_assistant = req
472
- .messages
473
- .last()
474
- .map(|m| m.role == "assistant")
475
- .unwrap_or(false);
476
-
477
- let history_end_index = if last_is_assistant {
478
- req.messages.len()
479
- } else {
480
- history_end_index
481
- };
482
-
483
- // 收集并配对消息
484
- let mut user_buffer: Vec<&super::types::Message> = Vec::new();
485
-
486
- for i in 0..history_end_index {
487
- let msg = &req.messages[i];
488
-
489
- if msg.role == "user" {
490
- user_buffer.push(msg);
491
- } else if msg.role == "assistant" {
492
- // 遇到 assistant,处理累积的 user 消息
493
- if !user_buffer.is_empty() {
494
- let merged_user = merge_user_messages(&user_buffer, model_id)?;
495
- history.push(Message::User(merged_user));
496
- user_buffer.clear();
497
-
498
- // 添加 assistant 消息
499
- let assistant = convert_assistant_message(msg)?;
500
- history.push(Message::Assistant(assistant));
501
- }
502
- }
503
- }
504
-
505
- // 处理结尾的孤立 user 消息
506
- if !user_buffer.is_empty() {
507
- let merged_user = merge_user_messages(&user_buffer, model_id)?;
508
- history.push(Message::User(merged_user));
509
-
510
- // 自动配对一个 "OK" 的 assistant 响应
511
- let auto_assistant = HistoryAssistantMessage::new("OK");
512
- history.push(Message::Assistant(auto_assistant));
513
- }
514
-
515
- Ok(history)
516
- }
517
-
518
- /// 合并多个 user 消息
519
- fn merge_user_messages(
520
- messages: &[&super::types::Message],
521
- model_id: &str,
522
- ) -> Result<HistoryUserMessage, ConversionError> {
523
- let mut content_parts = Vec::new();
524
- let mut all_images = Vec::new();
525
- let mut all_tool_results = Vec::new();
526
-
527
- for msg in messages {
528
- let (text, images, tool_results) = process_message_content(&msg.content)?;
529
- if !text.is_empty() {
530
- content_parts.push(text);
531
- }
532
- all_images.extend(images);
533
- all_tool_results.extend(tool_results);
534
- }
535
-
536
- let content = content_parts.join("\n");
537
- // 保留文本内容,即使有工具结果也不丢弃用户文本
538
- let mut user_msg = UserMessage::new(&content, model_id);
539
-
540
- if !all_images.is_empty() {
541
- user_msg = user_msg.with_images(all_images);
542
- }
543
-
544
- if !all_tool_results.is_empty() {
545
- let mut ctx = UserInputMessageContext::new();
546
- ctx = ctx.with_tool_results(all_tool_results);
547
- user_msg = user_msg.with_context(ctx);
548
- }
549
-
550
- Ok(HistoryUserMessage {
551
- user_input_message: user_msg,
552
- })
553
- }
554
-
555
- /// 转换 assistant 消息
556
- fn convert_assistant_message(
557
- msg: &super::types::Message,
558
- ) -> Result<HistoryAssistantMessage, ConversionError> {
559
- let mut thinking_content = String::new();
560
- let mut text_content = String::new();
561
- let mut tool_uses = Vec::new();
562
-
563
- match &msg.content {
564
- serde_json::Value::String(s) => {
565
- text_content = s.clone();
566
- }
567
- serde_json::Value::Array(arr) => {
568
- for item in arr {
569
- if let Ok(block) = serde_json::from_value::<ContentBlock>(item.clone()) {
570
- match block.block_type.as_str() {
571
- "thinking" => {
572
- if let Some(thinking) = block.thinking {
573
- thinking_content.push_str(&thinking);
574
- }
575
- }
576
- "text" => {
577
- if let Some(text) = block.text {
578
- text_content.push_str(&text);
579
- }
580
- }
581
- "tool_use" => {
582
- if let (Some(id), Some(name)) = (block.id, block.name) {
583
- let input = block.input.unwrap_or(serde_json::json!({}));
584
- tool_uses.push(ToolUseEntry::new(id, name).with_input(input));
585
- }
586
- }
587
- _ => {}
588
- }
589
- }
590
- }
591
- }
592
- _ => {}
593
- }
594
-
595
- // 组合 thinking 和 text 内容
596
- // 格式: <thinking>思考内容</thinking>\n\ntext内容
597
- // 注意: Kiro API 要求 content 字段不能为空,当只有 tool_use 时需要占位符
598
- let final_content = if !thinking_content.is_empty() {
599
- if !text_content.is_empty() {
600
- format!(
601
- "<thinking>{}</thinking>\n\n{}",
602
- thinking_content, text_content
603
- )
604
- } else {
605
- format!("<thinking>{}</thinking>", thinking_content)
606
- }
607
- } else if text_content.is_empty() && !tool_uses.is_empty() {
608
- "There is a tool use.".to_string()
609
- } else {
610
- text_content
611
- };
612
-
613
- let mut assistant = AssistantMessage::new(final_content);
614
- if !tool_uses.is_empty() {
615
- assistant = assistant.with_tool_uses(tool_uses);
616
- }
617
-
618
- Ok(HistoryAssistantMessage {
619
- assistant_response_message: assistant,
620
- })
621
- }
622
-
623
- #[cfg(test)]
624
- mod tests {
625
- use super::*;
626
-
627
- #[test]
628
- fn test_map_model_sonnet() {
629
- assert!(
630
- map_model("claude-sonnet-4-20250514")
631
- .unwrap()
632
- .contains("sonnet")
633
- );
634
- assert!(
635
- map_model("claude-3-5-sonnet-20241022")
636
- .unwrap()
637
- .contains("sonnet")
638
- );
639
- }
640
-
641
- #[test]
642
- fn test_map_model_opus() {
643
- assert!(
644
- map_model("claude-opus-4-20250514")
645
- .unwrap()
646
- .contains("opus")
647
- );
648
- }
649
-
650
- #[test]
651
- fn test_map_model_haiku() {
652
- assert!(
653
- map_model("claude-haiku-4-20250514")
654
- .unwrap()
655
- .contains("haiku")
656
- );
657
- }
658
-
659
- #[test]
660
- fn test_map_model_unsupported() {
661
- assert!(map_model("gpt-4").is_none());
662
- }
663
-
664
- #[test]
665
- fn test_determine_chat_trigger_type() {
666
- // 无工具时返回 MANUAL
667
- let req = MessagesRequest {
668
- model: "claude-sonnet-4".to_string(),
669
- max_tokens: 1024,
670
- messages: vec![],
671
- stream: false,
672
- system: None,
673
- tools: None,
674
- tool_choice: None,
675
- thinking: None,
676
- metadata: None,
677
- };
678
- assert_eq!(determine_chat_trigger_type(&req), "MANUAL");
679
- }
680
-
681
- #[test]
682
- fn test_collect_history_tool_names() {
683
- use crate::kiro::model::requests::tool::ToolUseEntry;
684
-
685
- // 创建包含工具使用的历史消息
686
- let mut assistant_msg = AssistantMessage::new("I'll read the file.");
687
- assistant_msg = assistant_msg.with_tool_uses(vec![
688
- ToolUseEntry::new("tool-1", "read")
689
- .with_input(serde_json::json!({"path": "/test.txt"})),
690
- ToolUseEntry::new("tool-2", "write")
691
- .with_input(serde_json::json!({"path": "/out.txt"})),
692
- ]);
693
-
694
- let history = vec![
695
- Message::User(HistoryUserMessage::new(
696
- "Read the file",
697
- "claude-sonnet-4.5",
698
- )),
699
- Message::Assistant(HistoryAssistantMessage {
700
- assistant_response_message: assistant_msg,
701
- }),
702
- ];
703
-
704
- let tool_names = collect_history_tool_names(&history);
705
- assert_eq!(tool_names.len(), 2);
706
- assert!(tool_names.contains(&"read".to_string()));
707
- assert!(tool_names.contains(&"write".to_string()));
708
- }
709
-
710
- #[test]
711
- fn test_create_placeholder_tool() {
712
- let tool = create_placeholder_tool("my_custom_tool");
713
-
714
- assert_eq!(tool.tool_specification.name, "my_custom_tool");
715
- assert!(!tool.tool_specification.description.is_empty());
716
-
717
- // 验证 JSON 序列化正确
718
- let json = serde_json::to_string(&tool).unwrap();
719
- assert!(json.contains("\"name\":\"my_custom_tool\""));
720
- }
721
-
722
- #[test]
723
- fn test_history_tools_added_to_tools_list() {
724
- use super::super::types::Message as AnthropicMessage;
725
-
726
- // 创建一个请求,历史中有工具使用,但 tools 列表为空
727
- let req = MessagesRequest {
728
- model: "claude-sonnet-4".to_string(),
729
- max_tokens: 1024,
730
- messages: vec![
731
- AnthropicMessage {
732
- role: "user".to_string(),
733
- content: serde_json::json!("Read the file"),
734
- },
735
- AnthropicMessage {
736
- role: "assistant".to_string(),
737
- content: serde_json::json!([
738
- {"type": "text", "text": "I'll read the file."},
739
- {"type": "tool_use", "id": "tool-1", "name": "read", "input": {"path": "/test.txt"}}
740
- ]),
741
- },
742
- AnthropicMessage {
743
- role: "user".to_string(),
744
- content: serde_json::json!([
745
- {"type": "tool_result", "tool_use_id": "tool-1", "content": "file content"}
746
- ]),
747
- },
748
- ],
749
- stream: false,
750
- system: None,
751
- tools: None, // 没有提供工具定义
752
- tool_choice: None,
753
- thinking: None,
754
- metadata: None,
755
- };
756
-
757
- let result = convert_request(&req).unwrap();
758
-
759
- // 验证 tools 列表中包含了历史中使用的工具的占位符定义
760
- let tools = &result
761
- .conversation_state
762
- .current_message
763
- .user_input_message
764
- .user_input_message_context
765
- .tools;
766
-
767
- assert!(!tools.is_empty(), "tools 列表不应为空");
768
- assert!(
769
- tools.iter().any(|t| t.tool_specification.name == "read"),
770
- "tools 列表应包含 'read' 工具的占位符定义"
771
- );
772
- }
773
-
774
- #[test]
775
- fn test_extract_session_id_valid() {
776
- // 测试有效的 user_id 格式
777
- let user_id = "user_0dede55c6dcc4a11a30bbb5e7f22e6fdf86cdeba3820019cc27612af4e1243cd_account__session_8bb5523b-ec7c-4540-a9ca-beb6d79f1552";
778
- let session_id = extract_session_id(user_id);
779
- assert_eq!(
780
- session_id,
781
- Some("8bb5523b-ec7c-4540-a9ca-beb6d79f1552".to_string())
782
- );
783
- }
784
-
785
- #[test]
786
- fn test_extract_session_id_no_session() {
787
- // 测试没有 session 的 user_id
788
- let user_id = "user_0dede55c6dcc4a11a30bbb5e7f22e6fdf86cdeba3820019cc27612af4e1243cd";
789
- let session_id = extract_session_id(user_id);
790
- assert_eq!(session_id, None);
791
- }
792
-
793
- #[test]
794
- fn test_extract_session_id_invalid_uuid() {
795
- // 测试无效的 UUID 格式
796
- let user_id = "user_xxx_session_invalid-uuid";
797
- let session_id = extract_session_id(user_id);
798
- assert_eq!(session_id, None);
799
- }
800
-
801
- #[test]
802
- fn test_convert_request_with_session_metadata() {
803
- use super::super::types::{Message as AnthropicMessage, Metadata};
804
-
805
- // 测试带有 metadata 的请求,应该使用 session UUID 作为 conversationId
806
- let req = MessagesRequest {
807
- model: "claude-sonnet-4".to_string(),
808
- max_tokens: 1024,
809
- messages: vec![AnthropicMessage {
810
- role: "user".to_string(),
811
- content: serde_json::json!("Hello"),
812
- }],
813
- stream: false,
814
- system: None,
815
- tools: None,
816
- tool_choice: None,
817
- thinking: None,
818
- metadata: Some(Metadata {
819
- user_id: Some(
820
- "user_0dede55c6dcc4a11a30bbb5e7f22e6fdf86cdeba3820019cc27612af4e1243cd_account__session_a0662283-7fd3-4399-a7eb-52b9a717ae88".to_string(),
821
- ),
822
- }),
823
- };
824
-
825
- let result = convert_request(&req).unwrap();
826
- assert_eq!(
827
- result.conversation_state.conversation_id,
828
- "a0662283-7fd3-4399-a7eb-52b9a717ae88"
829
- );
830
- }
831
-
832
- #[test]
833
- fn test_convert_request_without_metadata() {
834
- use super::super::types::Message as AnthropicMessage;
835
-
836
- // 测试没有 metadata 的请求,应该生成新的 UUID
837
- let req = MessagesRequest {
838
- model: "claude-sonnet-4".to_string(),
839
- max_tokens: 1024,
840
- messages: vec![AnthropicMessage {
841
- role: "user".to_string(),
842
- content: serde_json::json!("Hello"),
843
- }],
844
- stream: false,
845
- system: None,
846
- tools: None,
847
- tool_choice: None,
848
- thinking: None,
849
- metadata: None,
850
- };
851
-
852
- let result = convert_request(&req).unwrap();
853
- // 验证生成的是有效的 UUID 格式
854
- assert_eq!(result.conversation_state.conversation_id.len(), 36);
855
- assert_eq!(
856
- result
857
- .conversation_state
858
- .conversation_id
859
- .chars()
860
- .filter(|c| *c == '-')
861
- .count(),
862
- 4
863
- );
864
- }
865
-
866
- #[test]
867
- fn test_validate_tool_pairing_orphaned_result() {
868
- // 测试孤立的 tool_result 被过滤
869
- // 历史中没有 tool_use,但 tool_results 中有 tool_result
870
- let history = vec![
871
- Message::User(HistoryUserMessage::new("Hello", "claude-sonnet-4.5")),
872
- Message::Assistant(HistoryAssistantMessage::new("Hi there!")),
873
- ];
874
-
875
- let tool_results = vec![ToolResult::success("orphan-123", "some result")];
876
-
877
- let filtered = validate_tool_pairing(&history, &tool_results);
878
-
879
- // 孤立的 tool_result 应该被过滤掉
880
- assert!(filtered.is_empty(), "孤立的 tool_result 应该被过滤");
881
- }
882
-
883
- #[test]
884
- fn test_validate_tool_pairing_orphaned_use() {
885
- use crate::kiro::model::requests::tool::ToolUseEntry;
886
-
887
- // 测试孤立的 tool_use(有 tool_use 但没有对应的 tool_result)
888
- let mut assistant_msg = AssistantMessage::new("I'll read the file.");
889
- assistant_msg = assistant_msg.with_tool_uses(vec![ToolUseEntry::new("tool-orphan", "read")
890
- .with_input(serde_json::json!({"path": "/test.txt"}))]);
891
-
892
- let history = vec![
893
- Message::User(HistoryUserMessage::new(
894
- "Read the file",
895
- "claude-sonnet-4.5",
896
- )),
897
- Message::Assistant(HistoryAssistantMessage {
898
- assistant_response_message: assistant_msg,
899
- }),
900
- ];
901
-
902
- // 没有 tool_result
903
- let tool_results: Vec<ToolResult> = vec![];
904
-
905
- let filtered = validate_tool_pairing(&history, &tool_results);
906
-
907
- // 结果应该为空(因为没有 tool_result)
908
- // 同时应该输出警告日志(孤立的 tool_use)
909
- assert!(filtered.is_empty());
910
- }
911
-
912
- #[test]
913
- fn test_validate_tool_pairing_valid() {
914
- use crate::kiro::model::requests::tool::ToolUseEntry;
915
-
916
- // 测试正常配对的情况
917
- let mut assistant_msg = AssistantMessage::new("I'll read the file.");
918
- assistant_msg = assistant_msg.with_tool_uses(vec![ToolUseEntry::new("tool-1", "read")
919
- .with_input(serde_json::json!({"path": "/test.txt"}))]);
920
-
921
- let history = vec![
922
- Message::User(HistoryUserMessage::new(
923
- "Read the file",
924
- "claude-sonnet-4.5",
925
- )),
926
- Message::Assistant(HistoryAssistantMessage {
927
- assistant_response_message: assistant_msg,
928
- }),
929
- ];
930
-
931
- let tool_results = vec![ToolResult::success("tool-1", "file content")];
932
-
933
- let filtered = validate_tool_pairing(&history, &tool_results);
934
-
935
- // 配对成功,应该保留
936
- assert_eq!(filtered.len(), 1);
937
- assert_eq!(filtered[0].tool_use_id, "tool-1");
938
- }
939
-
940
- #[test]
941
- fn test_validate_tool_pairing_mixed() {
942
- use crate::kiro::model::requests::tool::ToolUseEntry;
943
-
944
- // 测试混合情况:部分配对成功,部分孤立
945
- let mut assistant_msg = AssistantMessage::new("I'll use two tools.");
946
- assistant_msg = assistant_msg.with_tool_uses(vec![
947
- ToolUseEntry::new("tool-1", "read").with_input(serde_json::json!({})),
948
- ToolUseEntry::new("tool-2", "write").with_input(serde_json::json!({})),
949
- ]);
950
-
951
- let history = vec![
952
- Message::User(HistoryUserMessage::new("Do something", "claude-sonnet-4.5")),
953
- Message::Assistant(HistoryAssistantMessage {
954
- assistant_response_message: assistant_msg,
955
- }),
956
- ];
957
-
958
- // tool_results: tool-1 配对,tool-3 孤立
959
- let tool_results = vec![
960
- ToolResult::success("tool-1", "result 1"),
961
- ToolResult::success("tool-3", "orphan result"), // 孤立
962
- ];
963
-
964
- let filtered = validate_tool_pairing(&history, &tool_results);
965
-
966
- // 只有 tool-1 应该保留
967
- assert_eq!(filtered.len(), 1);
968
- assert_eq!(filtered[0].tool_use_id, "tool-1");
969
- // tool-2 是孤立的 tool_use(无 result),tool-3 是孤立的 tool_result
970
- }
971
-
972
- #[test]
973
- fn test_validate_tool_pairing_history_already_paired() {
974
- use crate::kiro::model::requests::tool::ToolUseEntry;
975
-
976
- // 测试历史中已配对的 tool_use 不应该被报告为孤立
977
- // 场景:多轮对话中,之前的 tool_use 已经在历史中有对应的 tool_result
978
- let mut assistant_msg1 = AssistantMessage::new("I'll read the file.");
979
- assistant_msg1 = assistant_msg1.with_tool_uses(vec![ToolUseEntry::new("tool-1", "read")
980
- .with_input(serde_json::json!({"path": "/test.txt"}))]);
981
-
982
- // 构建历史中的 user 消息,包含 tool_result
983
- let mut user_msg_with_result = UserMessage::new("", "claude-sonnet-4.5");
984
- let mut ctx = UserInputMessageContext::new();
985
- ctx = ctx.with_tool_results(vec![ToolResult::success("tool-1", "file content")]);
986
- user_msg_with_result = user_msg_with_result.with_context(ctx);
987
-
988
- let history = vec![
989
- // 第一轮:用户请求
990
- Message::User(HistoryUserMessage::new(
991
- "Read the file",
992
- "claude-sonnet-4.5",
993
- )),
994
- // 第一轮:assistant 使用工具
995
- Message::Assistant(HistoryAssistantMessage {
996
- assistant_response_message: assistant_msg1,
997
- }),
998
- // 第二轮:用户返回工具结果(历史中已配对)
999
- Message::User(HistoryUserMessage {
1000
- user_input_message: user_msg_with_result,
1001
- }),
1002
- // 第二轮:assistant 响应
1003
- Message::Assistant(HistoryAssistantMessage::new("The file contains...")),
1004
- ];
1005
-
1006
- // 当前消息没有 tool_results(用户只是继续对话)
1007
- let tool_results: Vec<ToolResult> = vec![];
1008
-
1009
- let filtered = validate_tool_pairing(&history, &tool_results);
1010
-
1011
- // 结果应该为空,且不应该有孤立 tool_use 的警告
1012
- // 因为 tool-1 已经在历史中配对了
1013
- assert!(filtered.is_empty());
1014
- }
1015
-
1016
- #[test]
1017
- fn test_validate_tool_pairing_duplicate_result() {
1018
- use crate::kiro::model::requests::tool::ToolUseEntry;
1019
-
1020
- // 测试重复的 tool_result(历史中已配对,当前消息又发送了相同的 tool_result)
1021
- let mut assistant_msg = AssistantMessage::new("I'll read the file.");
1022
- assistant_msg = assistant_msg.with_tool_uses(vec![ToolUseEntry::new("tool-1", "read")
1023
- .with_input(serde_json::json!({"path": "/test.txt"}))]);
1024
-
1025
- // 历史中已有 tool_result
1026
- let mut user_msg_with_result = UserMessage::new("", "claude-sonnet-4.5");
1027
- let mut ctx = UserInputMessageContext::new();
1028
- ctx = ctx.with_tool_results(vec![ToolResult::success("tool-1", "file content")]);
1029
- user_msg_with_result = user_msg_with_result.with_context(ctx);
1030
-
1031
- let history = vec![
1032
- Message::User(HistoryUserMessage::new(
1033
- "Read the file",
1034
- "claude-sonnet-4.5",
1035
- )),
1036
- Message::Assistant(HistoryAssistantMessage {
1037
- assistant_response_message: assistant_msg,
1038
- }),
1039
- Message::User(HistoryUserMessage {
1040
- user_input_message: user_msg_with_result,
1041
- }),
1042
- Message::Assistant(HistoryAssistantMessage::new("Done")),
1043
- ];
1044
-
1045
- // 当前消息又发送了相同的 tool_result(重复)
1046
- let tool_results = vec![ToolResult::success("tool-1", "file content again")];
1047
-
1048
- let filtered = validate_tool_pairing(&history, &tool_results);
1049
-
1050
- // 重复的 tool_result 应该被过滤掉
1051
- assert!(filtered.is_empty(), "重复的 tool_result 应该被过滤");
1052
- }
1053
-
1054
- #[test]
1055
- fn test_convert_assistant_message_tool_use_only() {
1056
- use super::super::types::Message as AnthropicMessage;
1057
-
1058
- // 测试仅包含 tool_use 的 assistant 消息(无 text 块)
1059
- // Kiro API 要求 content 字段不能为空
1060
- let msg = AnthropicMessage {
1061
- role: "assistant".to_string(),
1062
- content: serde_json::json!([
1063
- {"type": "tool_use", "id": "toolu_01ABC", "name": "read_file", "input": {"path": "/test.txt"}}
1064
- ]),
1065
- };
1066
-
1067
- let result = convert_assistant_message(&msg).expect("应该成功转换");
1068
-
1069
- // 验证 content 不为空(使用占位符)
1070
- assert!(
1071
- !result.assistant_response_message.content.is_empty(),
1072
- "content 不应为空"
1073
- );
1074
- assert_eq!(
1075
- result.assistant_response_message.content, "There is a tool use.",
1076
- "仅 tool_use 时应使用 'There is a tool use.' 占位符"
1077
- );
1078
-
1079
- // 验证 tool_uses 被正确保留
1080
- let tool_uses = result
1081
- .assistant_response_message
1082
- .tool_uses
1083
- .expect("应该有 tool_uses");
1084
- assert_eq!(tool_uses.len(), 1);
1085
- assert_eq!(tool_uses[0].tool_use_id, "toolu_01ABC");
1086
- assert_eq!(tool_uses[0].name, "read_file");
1087
- }
1088
-
1089
- #[test]
1090
- fn test_convert_assistant_message_with_text_and_tool_use() {
1091
- use super::super::types::Message as AnthropicMessage;
1092
-
1093
- // 测试同时包含 text 和 tool_use 的 assistant 消息
1094
- let msg = AnthropicMessage {
1095
- role: "assistant".to_string(),
1096
- content: serde_json::json!([
1097
- {"type": "text", "text": "Let me read that file for you."},
1098
- {"type": "tool_use", "id": "toolu_02XYZ", "name": "read_file", "input": {"path": "/data.json"}}
1099
- ]),
1100
- };
1101
-
1102
- let result = convert_assistant_message(&msg).expect("应该成功转换");
1103
-
1104
- // 验证 content 使用原始文本(不是占位符)
1105
- assert_eq!(
1106
- result.assistant_response_message.content,
1107
- "Let me read that file for you."
1108
- );
1109
-
1110
- // 验证 tool_uses 被正确保留
1111
- let tool_uses = result
1112
- .assistant_response_message
1113
- .tool_uses
1114
- .expect("应该有 tool_uses");
1115
- assert_eq!(tool_uses.len(), 1);
1116
- assert_eq!(tool_uses[0].tool_use_id, "toolu_02XYZ");
1117
- }
1118
- }