1041006580 commited on
Commit ·
ddae732
1
Parent(s): 9843e1b
chore: 清理HuggingFace Space仓库,只保留部署必需文件
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +0 -54
- .github/workflows/build.yaml +0 -85
- .github/workflows/docker-build.yaml +0 -87
- .github/workflows/docker-build.yml +0 -55
- .gitignore +0 -12
- Cargo.toml +0 -34
- Dockerfile +0 -42
- admin-ui/index.html +0 -13
- admin-ui/package.json +0 -37
- admin-ui/postcss.config.js +0 -6
- admin-ui/public/vite.svg +0 -1
- admin-ui/src/App.tsx +0 -37
- admin-ui/src/api/credentials.ts +0 -86
- admin-ui/src/components/add-credential-dialog.tsx +0 -186
- admin-ui/src/components/balance-dialog.tsx +0 -104
- admin-ui/src/components/credential-card.tsx +0 -298
- admin-ui/src/components/dashboard.tsx +0 -186
- admin-ui/src/components/login-page.tsx +0 -62
- admin-ui/src/components/ui/badge.tsx +0 -39
- admin-ui/src/components/ui/button.tsx +0 -55
- admin-ui/src/components/ui/card.tsx +0 -78
- admin-ui/src/components/ui/dialog.tsx +0 -119
- admin-ui/src/components/ui/input.tsx +0 -24
- admin-ui/src/components/ui/progress.tsx +0 -35
- admin-ui/src/components/ui/sonner.tsx +0 -25
- admin-ui/src/components/ui/switch.tsx +0 -26
- admin-ui/src/hooks/use-credentials.ts +0 -87
- admin-ui/src/index.css +0 -60
- admin-ui/src/lib/storage.ts +0 -7
- admin-ui/src/lib/utils.ts +0 -106
- admin-ui/src/main.tsx +0 -22
- admin-ui/src/types/api.ts +0 -69
- admin-ui/tailwind.config.js +0 -53
- admin-ui/tsconfig.json +0 -24
- admin-ui/vite.config.ts +0 -25
- config.example.json +0 -7
- credentials.example.idc.json +0 -9
- credentials.example.multiple.json +0 -19
- credentials.example.social.json +0 -6
- entrypoint.sh +0 -61
- src/admin/error.rs +0 -64
- src/admin/handlers.rs +0 -104
- src/admin/middleware.rs +0 -50
- src/admin/mod.rs +0 -28
- src/admin/router.rs +0 -47
- src/admin/service.rs +0 -234
- src/admin/types.rs +0 -187
- src/admin_ui/mod.rs +0 -7
- src/admin_ui/router.rs +0 -109
- 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|